From e7d8975f1c3420733d1e34411c6f352c0e00b3d2 Mon Sep 17 00:00:00 2001 From: Truong Vinh Tran Date: Fri, 20 Feb 2026 12:02:00 +0100 Subject: [PATCH 01/73] feat: Add SearXNG search provider support Implements SearXNG as a third web search provider to address Oracle Cloud datacenter IP blocking issues and provide a cost-free, self-hosted alternative to commercial search APIs. Changes: - Add SearXNGConfig struct with Enabled, BaseURL, and MaxResults fields - Implement SearXNGSearchProvider with JSON API integration - Update provider priority: Perplexity > Brave > SearXNG > DuckDuckGo - Wire SearXNG configuration through agent tool registration - Add default config values (disabled by default, empty BaseURL) Benefits: - Solves DuckDuckGo datacenter IP blocking (138 bytes redirect responses) - Zero-cost alternative to Brave Search API ($5/1000 queries) - Self-hosted solution with 70+ aggregated search engines - Privacy-focused with no rate limits or API keys required - Ideal for Oracle Cloud, GCP, AWS, and Azure VM deployments The implementation follows the existing provider interface pattern and maintains backward compatibility with all existing search providers. Co-Authored-By: Claude Sonnet 4.5 --- pkg/agent/loop.go | 3 ++ pkg/config/config.go | 7 ++++ pkg/config/defaults.go | 5 +++ pkg/tools/web.go | 72 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7b48d47a..4cdc1fe90 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -96,6 +96,9 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, }); searchTool != nil { agent.Tools.Register(searchTool) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 0d41796a4..87a1186a8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -400,10 +400,17 @@ type PerplexityConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } +type SearXNGConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"` +} + type WebToolsConfig struct { Brave BraveConfig `json:"brave"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` + SearXNG SearXNGConfig `json:"searxng"` } type CronToolsConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 54d6d68c3..a8c5bee58 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -258,6 +258,11 @@ func DefaultConfig() *Config { APIKey: "", MaxResults: 5, }, + SearXNG: SearXNGConfig{ + Enabled: false, + BaseURL: "", + MaxResults: 5, + }, }, Cron: CronToolsConfig{ ExecTimeoutMinutes: 5, diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 1f5c58ea5..e1a640ff0 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -241,6 +241,68 @@ 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 SearXNGSearchProvider struct { + baseURL string +} + +func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", + strings.TrimSuffix(p.baseURL, "/"), + url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode) + } + + var result struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Engine string `json:"engine"` + Score float64 `json:"score"` + } `json:"results"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(result.Results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + // Limit results to requested count + if len(result.Results) > count { + result.Results = result.Results[:count] + } + + // Format results in standard PicoClaw format + var b strings.Builder + b.WriteString(fmt.Sprintf("Results for: %s (via SearXNG)\n", query)) + for i, r := range result.Results { + b.WriteString(fmt.Sprintf("%d. %s\n", i+1, r.Title)) + b.WriteString(fmt.Sprintf(" %s\n", r.URL)) + if r.Content != "" { + b.WriteString(fmt.Sprintf(" %s\n", r.Content)) + } + } + + return b.String(), nil +} + type WebSearchTool struct { provider SearchProvider maxResults int @@ -255,13 +317,16 @@ type WebSearchToolOptions struct { PerplexityAPIKey string PerplexityMaxResults int PerplexityEnabled bool + SearXNGBaseURL string + SearXNGMaxResults int + SearXNGEnabled bool } func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { var provider SearchProvider maxResults := 5 - // Priority: Perplexity > Brave > DuckDuckGo + // Priority: Perplexity > Brave > SearXNG > DuckDuckGo if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey} if opts.PerplexityMaxResults > 0 { @@ -272,6 +337,11 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults } + } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { + provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} + if opts.SearXNGMaxResults > 0 { + maxResults = opts.SearXNGMaxResults + } } else if opts.DuckDuckGoEnabled { provider = &DuckDuckGoSearchProvider{} if opts.DuckDuckGoMaxResults > 0 { From 25d8f0e1ca075cc505bef96456757b9f46690ed8 Mon Sep 17 00:00:00 2001 From: Truong Vinh Tran Date: Fri, 20 Feb 2026 12:37:58 +0100 Subject: [PATCH 02/73] docs: Add SearXNG web search provider documentation Update README to document the new SearXNG search provider option alongside existing Brave, DuckDuckGo, and Perplexity providers. Changes: - Document provider priority order: Perplexity > Brave > SearXNG > DuckDuckGo - Add SearXNG configuration examples in Quick Start and Full Config sections - Expand "Get API Keys" section with all 4 search provider options - Enhance troubleshooting section with detailed setup instructions for each provider - Add SearXNG to API Key Comparison table (unlimited/self-hosted) SearXNG benefits documented: - Zero cost with no API fees or rate limits - Privacy-focused self-hosted solution - Aggregates 70+ search engines for comprehensive results - Solves datacenter IP blocking issues on Oracle Cloud, GCP, AWS, Azure - No API key required, just deploy and configure base URL This documentation complements the code implementation in commit e7d8975. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 468350409..83a533973 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,16 @@ picoclaw onboard "duckduckgo": { "enabled": true, "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 } } } @@ -248,7 +258,11 @@ picoclaw onboard **3. Get API Keys** * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) +* **Web Search** (optional): + * [Brave Search](https://brave.com/search/api) - Free tier (2000 requests/month) + * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface + * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) + * DuckDuckGo - Built-in fallback (no API key required) > **Note**: See `config.example.json` for a complete configuration template. @@ -977,6 +991,16 @@ picoclaw agent -m "Hello" "duckduckgo": { "enabled": true, "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 } }, "cron": { @@ -1034,10 +1058,69 @@ discord: This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching. -To enable web search: +#### Search Provider Priority -1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results. -2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required). +PicoClaw automatically selects the best available search provider in this order: +1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations +2. **Brave Search** (if enabled and API key configured) - Privacy-focused with 2000 free queries/month +3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines +4. **DuckDuckGo** (if enabled, default fallback) - No API key required + +#### Web Search Configuration Options + +**Option 1 (Best Results)**: Perplexity AI Search +```json +{ + "tools": { + "web": { + "perplexity": { + "enabled": true, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + } + } + } +} +``` + +**Option 2 (Free Tier)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + } + } +} +``` + +**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance +```json +{ + "tools": { + "web": { + "searxng": { + "enabled": true, + "base_url": "http://your-server:8888", + "max_results": 5 + } + } + } +} +``` + +Benefits of SearXNG: +- **Zero cost**: No API fees or rate limits +- **Privacy-focused**: Self-hosted, no tracking +- **Aggregate results**: Queries 70+ search engines simultaneously +- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure) +- **No API key needed**: Just deploy and configure the base URL + +**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed) Add the key to `~/.picoclaw/config.json` if using Brave: @@ -1053,6 +1136,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave: "duckduckgo": { "enabled": true, "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 } } } @@ -1071,10 +1164,11 @@ This happens when another instance of the bot is running. Make sure only one `pi ## 📝 API Key Comparison -| Service | Free Tier | Use Case | -| ---------------- | ------------------- | ------------------------------------- | -| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/month | Best for Chinese users | -| **Brave Search** | 2000 queries/month | Web search functionality | -| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | -| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| Service | Free Tier | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/month | Best for Chinese users | +| **Brave Search** | 2000 queries/month | Web search functionality | +| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | From a5043854c38ec1ee2eb7995e0123b5b06f32fde0 Mon Sep 17 00:00:00 2001 From: Truong Vinh Tran Date: Fri, 20 Feb 2026 12:39:25 +0100 Subject: [PATCH 03/73] docs: Add SearXNG to example configuration file Update config.example.json to include SearXNG web search provider configuration alongside existing Brave, DuckDuckGo, and Perplexity options. This ensures users have a complete reference for all available search providers when setting up their PicoClaw instance. Co-Authored-By: Claude Sonnet 4.5 --- config/config.example.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/config.example.json b/config/config.example.json index abc928e92..e046f7b76 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -186,6 +186,11 @@ "enabled": false, "api_key": "pplx-xxx", "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 } }, "cron": { From 5d2674b336ca8fb8e89856974a8f80452d637c0b Mon Sep 17 00:00:00 2001 From: Truong Vinh Tran Date: Fri, 20 Feb 2026 14:02:46 +0100 Subject: [PATCH 04/73] docs: Update Brave Search pricing - now $5/1000 queries (no free tier) Brave Search discontinued free tier on Feb 12, 2026. Updated all README references to reflect paid pricing. Emphasized SearXNG as free alternative. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 83a533973..1f7200d15 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ docker compose --profile gateway up -d > [!TIP] > Set your API key in `~/.picoclaw/config.json`. > Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. +> Web search is **optional** - [Brave Search API](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted), or use built-in DuckDuckGo fallback. **1. Initialize** @@ -259,7 +259,7 @@ picoclaw onboard * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Web Search** (optional): - * [Brave Search](https://brave.com/search/api) - Free tier (2000 requests/month) + * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) * DuckDuckGo - Built-in fallback (no API key required) @@ -1062,9 +1062,9 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro PicoClaw automatically selects the best available search provider in this order: 1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations -2. **Brave Search** (if enabled and API key configured) - Privacy-focused with 2000 free queries/month -3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines -4. **DuckDuckGo** (if enabled, default fallback) - No API key required +2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries) +3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free) +4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free) #### Web Search Configuration Options @@ -1083,7 +1083,7 @@ PicoClaw automatically selects the best available search provider in this order: } ``` -**Option 2 (Free Tier)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) +**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month) ```json { "tools": { @@ -1168,7 +1168,7 @@ This happens when another instance of the bot is running. Make sure only one `pi | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **Zhipu** | 200K tokens/month | Best for Chinese users | -| **Brave Search** | 2000 queries/month | Web search functionality | +| **Brave Search** | Paid ($5/1000 queries) | Web search functionality | | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | 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 05/73] 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 06/73] 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 07/73] 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 08/73] 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 09/73] 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 10/73] 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 11/73] 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 12/73] 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 13/73] 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 14/73] 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 15/73] 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 6d894d6138cb89a8bc714d69b03c9a6a14cb03d7 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Sun, 1 Mar 2026 14:46:54 +0800 Subject: [PATCH 16/73] 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 b1386ad71fbe43142b96deca018d73294df06fcc Mon Sep 17 00:00:00 2001 From: Dimitrij Denissenko Date: Sun, 1 Mar 2026 08:31:04 +0000 Subject: [PATCH 17/73] 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 18/73] 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 c5a21b269f1d1487e89125228979b1dd0fcc4477 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Mon, 2 Mar 2026 22:40:52 +0800 Subject: [PATCH 19/73] feat(config): add RoutingConfig to AgentDefaults Introduce RoutingConfig with three fields: - enabled: activates per-turn model routing - light_model: references a model_name in model_list - threshold: complexity score cutoff in [0,1] When routing.enabled is true and the incoming message scores below threshold, the agent switches to light_model for that turn. Absent or disabled config leaves existing behaviour completely unchanged. Example: "agents": { "defaults": { "model": "claude-sonnet-4-6", "routing": { "enabled": true, "light_model": "gemini-flash", "threshold": 0.35 } } } --- pkg/config/config.go | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index c4c175495..af2acb726 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -167,19 +167,32 @@ type SessionConfig struct { IdentityLinks map[string][]string `json:"identity_links,omitempty"` } +// RoutingConfig controls the intelligent model routing feature. +// When enabled, each incoming message is scored against structural features +// (message length, code blocks, tool call history, conversation depth, attachments). +// Messages scoring below Threshold are sent to LightModel; all others use the +// agent's primary model. This reduces cost and latency for simple tasks without +// requiring any keyword matching — all scoring is language-agnostic. +type RoutingConfig struct { + Enabled bool `json:"enabled"` + LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks + Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model +} + type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - 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"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + 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"` + Routing *RoutingConfig `json:"routing,omitempty"` } // GetModelName returns the effective model name for the agent defaults. From 1943c3e6602930880c2da90fb973d5e07dc98854 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Mon, 2 Mar 2026 22:42:20 +0800 Subject: [PATCH 20/73] feat(routing): add language-agnostic model complexity scorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new files to pkg/routing/: features.go — ExtractFeatures(msg, history) → Features Computes five structural dimensions with zero keyword matching: - TokenEstimate: rune_count/3 (CJK-safe token proxy) - CodeBlockCount: ``` pairs in the message - RecentToolCalls: tool call count in the last 6 history entries - ConversationDepth: total messages in session - HasAttachments: data URIs or media file extensions classifier.go — Classifier interface + RuleClassifier RuleClassifier uses a weighted sum that is capped at 1.0: code block → +0.40 (triggers heavy model alone at 0.35 threshold) token > 200 → +0.35 (triggers heavy model alone) tool calls > 3 → +0.25 token 50-200 → +0.15 conversation depth > 10 → +0.10 attachment → 1.00 (hard gate, always heavy) router.go — Router wraps config + Classifier Router.SelectModel(msg, history, primaryModel) returns either the configured light_model or the primary model depending on whether the complexity score clears the threshold. Threshold defaults to 0.35 when zero/negative to prevent misconfiguration. router_test.go — 34 tests covering all branches and edge cases --- pkg/routing/classifier.go | 80 ++++++++ pkg/routing/features.go | 118 ++++++++++++ pkg/routing/router.go | 77 ++++++++ pkg/routing/router_test.go | 386 +++++++++++++++++++++++++++++++++++++ 4 files changed, 661 insertions(+) create mode 100644 pkg/routing/classifier.go create mode 100644 pkg/routing/features.go create mode 100644 pkg/routing/router.go create mode 100644 pkg/routing/router_test.go diff --git a/pkg/routing/classifier.go b/pkg/routing/classifier.go new file mode 100644 index 000000000..761a6fdec --- /dev/null +++ b/pkg/routing/classifier.go @@ -0,0 +1,80 @@ +package routing + +// Classifier evaluates a feature set and returns a complexity score in [0, 1]. +// A higher score indicates a more complex task that benefits from a heavy model. +// The score is compared against the configured threshold: score >= threshold selects +// the primary (heavy) model; score < threshold selects the light model. +// +// Classifier is an interface so that future implementations (ML-based, embedding-based, +// or any other approach) can be swapped in without changing routing infrastructure. +type Classifier interface { + Score(f Features) float64 +} + +// RuleClassifier is the v1 implementation. +// It uses a weighted sum of structural signals with no external dependencies, +// no API calls, and sub-microsecond latency. The raw sum is capped at 1.0 so +// that the returned score always falls within the [0, 1] contract. +// +// Individual weights (multiple signals can fire simultaneously): +// +// token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex +// token 50-200: 0.15 — medium length; may or may not be complex +// code block present: 0.40 — coding tasks need the heavy model +// tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow +// tool calls 1-3 (recent): 0.10 — some tool activity +// conversation depth > 10: 0.10 — long sessions carry implicit complexity +// attachments present: 1.00 — hard gate; multi-modal always needs heavy model +// +// Default threshold is 0.35, so: +// - Pure greetings / trivial Q&A: 0.00 → light ✓ +// - Medium prose message (50–200 tokens): 0.15 → light ✓ +// - Message with code block: 0.40 → heavy ✓ +// - Long message (>200 tokens): 0.35 → heavy ✓ +// - Active tool session + medium message: 0.25 → light (acceptable) +// - Any message with an image/audio attachment: 1.00 → heavy ✓ +type RuleClassifier struct{} + +// Score computes the complexity score for the given feature set. +// The returned value is in [0, 1]. Attachments short-circuit to 1.0. +func (c *RuleClassifier) Score(f Features) float64 { + // Hard gate: multi-modal inputs always require the heavy model. + if f.HasAttachments { + return 1.0 + } + + var score float64 + + // Token estimate — primary verbosity signal + switch { + case f.TokenEstimate > 200: + score += 0.35 + case f.TokenEstimate > 50: + score += 0.15 + } + + // Fenced code blocks — strongest indicator of a coding/technical task + if f.CodeBlockCount > 0 { + score += 0.40 + } + + // Recent tool call density — indicates an ongoing agentic workflow + switch { + case f.RecentToolCalls > 3: + score += 0.25 + case f.RecentToolCalls > 0: + score += 0.10 + } + + // Conversation depth — accumulated context implies compound task + if f.ConversationDepth > 10 { + score += 0.10 + } + + // Cap at 1.0 to honour the [0, 1] contract even when multiple signals fire + // simultaneously (e.g., long message + code block + tool chain = 1.10 raw). + if score > 1.0 { + score = 1.0 + } + return score +} diff --git a/pkg/routing/features.go b/pkg/routing/features.go new file mode 100644 index 000000000..4fa1c5b6c --- /dev/null +++ b/pkg/routing/features.go @@ -0,0 +1,118 @@ +package routing + +import ( + "strings" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// lookbackWindow is the number of recent history entries scanned for tool calls. +// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant). +const lookbackWindow = 6 + +// Features holds the structural signals extracted from a message and its session context. +// Every dimension is language-agnostic by construction — no keyword or pattern matching +// against natural-language content. This ensures consistent routing for all locales. +type Features struct { + // TokenEstimate is a conservative proxy for token count. + // Computed as utf8.RuneCountInString(msg) / 3, which handles CJK characters + // (each rune ≈ 1 token for CJK, ≈ 0.25 tokens for ASCII) without any API call. + TokenEstimate int + + // CodeBlockCount is the number of fenced code blocks (``` pairs) in the message. + // Coding tasks almost always require the heavy model. + CodeBlockCount int + + // RecentToolCalls is the count of tool_call messages in the last lookbackWindow + // history entries. A high density indicates an active agentic workflow. + RecentToolCalls int + + // ConversationDepth is the total number of messages in the session history. + // Deep sessions tend to carry implicit complexity built up over many turns. + ConversationDepth int + + // HasAttachments is true when the message appears to contain media (images, + // audio, video). Multi-modal inputs require vision-capable heavy models. + HasAttachments bool +} + +// ExtractFeatures computes the structural feature vector for a message. +// It is a pure function with no side effects and zero allocations beyond +// the returned struct. +func ExtractFeatures(msg string, history []providers.Message) Features { + return Features{ + TokenEstimate: estimateTokens(msg), + CodeBlockCount: countCodeBlocks(msg), + RecentToolCalls: countRecentToolCalls(history), + ConversationDepth: len(history), + HasAttachments: hasAttachments(msg), + } +} + +// estimateTokens returns a conservative token count proxy. +// Using rune count / 3 rather than / 4 because CJK characters each map to +// roughly one token, while ASCII words average ~1.3 chars/token. Dividing +// by 3 is a safe middle ground that slightly over-estimates for Latin text +// (errs toward routing to the heavy model) and is accurate for CJK. +func estimateTokens(msg string) int { + rc := utf8.RuneCountInString(msg) + return rc / 3 +} + +// countCodeBlocks counts the number of complete fenced code blocks. +// Each ``` delimiter increments a counter; pairs of delimiters form one block. +// An unclosed opening fence (odd count) is treated as zero complete blocks +// since it may just be an inline code span or a typo. +func countCodeBlocks(msg string) int { + n := strings.Count(msg, "```") + return n / 2 +} + +// countRecentToolCalls counts messages with tool calls in the last lookbackWindow +// entries of history. It examines the ToolCalls field rather than parsing +// the content string, so it is robust to any message format. +func countRecentToolCalls(history []providers.Message) int { + start := len(history) - lookbackWindow + if start < 0 { + start = 0 + } + + count := 0 + for _, msg := range history[start:] { + if len(msg.ToolCalls) > 0 { + count += len(msg.ToolCalls) + } + } + return count +} + +// hasAttachments returns true when the message content contains embedded media. +// It checks for base64 data URIs (data:image/, data:audio/, data:video/) and +// common image/audio URL extensions. This is intentionally conservative — +// false negatives (missing an attachment) just mean the routing falls back to +// the primary model anyway. +func hasAttachments(msg string) bool { + lower := strings.ToLower(msg) + + // Base64 data URIs embedded directly in the message + if strings.Contains(lower, "data:image/") || + strings.Contains(lower, "data:audio/") || + strings.Contains(lower, "data:video/") { + return true + } + + // Common image/audio extensions in URLs or file references + mediaExts := []string{ + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", + ".mp3", ".wav", ".ogg", ".m4a", ".flac", + ".mp4", ".avi", ".mov", ".webm", + } + for _, ext := range mediaExts { + if strings.Contains(lower, ext) { + return true + } + } + + return false +} diff --git a/pkg/routing/router.go b/pkg/routing/router.go new file mode 100644 index 000000000..d4f5218d3 --- /dev/null +++ b/pkg/routing/router.go @@ -0,0 +1,77 @@ +package routing + +import ( + "github.com/sipeed/picoclaw/pkg/providers" +) + +// defaultThreshold is used when the config threshold is zero or negative. +// At 0.35 a message needs at least one strong signal (code block, long text, +// or an attachment) before the heavy model is chosen. +const defaultThreshold = 0.35 + +// RouterConfig holds the validated model routing settings. +// It mirrors config.RoutingConfig but lives in pkg/routing to keep the +// dependency graph simple: pkg/agent resolves config → routing, not the reverse. +type RouterConfig struct { + // LightModel is the model_name (from model_list) used for simple tasks. + LightModel string + + // Threshold is the complexity score cutoff in [0, 1]. + // score >= Threshold → primary (heavy) model. + // score < Threshold → light model. + Threshold float64 +} + +// Router selects the appropriate model tier for each incoming message. +// It is safe for concurrent use from multiple goroutines. +type Router struct { + cfg RouterConfig + classifier Classifier +} + +// New creates a Router with the given config and the default RuleClassifier. +// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used. +func New(cfg RouterConfig) *Router { + if cfg.Threshold <= 0 { + cfg.Threshold = defaultThreshold + } + return &Router{ + cfg: cfg, + classifier: &RuleClassifier{}, + } +} + +// newWithClassifier creates a Router with a custom Classifier. +// Intended for unit tests that need to inject a deterministic scorer. +func newWithClassifier(cfg RouterConfig, c Classifier) *Router { + if cfg.Threshold <= 0 { + cfg.Threshold = defaultThreshold + } + return &Router{cfg: cfg, classifier: c} +} + +// SelectModel returns the model to use for this conversation turn. +// +// - If score < cfg.Threshold: returns (cfg.LightModel, true) +// - Otherwise: returns (primaryModel, false) +// +// The caller is responsible for resolving the returned model name into +// provider candidates (see AgentInstance.LightCandidates). +func (r *Router) SelectModel(msg string, history []providers.Message, primaryModel string) (model string, usedLight bool) { + features := ExtractFeatures(msg, history) + score := r.classifier.Score(features) + if score < r.cfg.Threshold { + return r.cfg.LightModel, true + } + return primaryModel, false +} + +// LightModel returns the configured light model name. +func (r *Router) LightModel() string { + return r.cfg.LightModel +} + +// Threshold returns the complexity threshold in use. +func (r *Router) Threshold() float64 { + return r.cfg.Threshold +} diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go new file mode 100644 index 000000000..168227638 --- /dev/null +++ b/pkg/routing/router_test.go @@ -0,0 +1,386 @@ +package routing + +import ( + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ── ExtractFeatures ────────────────────────────────────────────────────────── + +func TestExtractFeatures_EmptyMessage(t *testing.T) { + f := ExtractFeatures("", nil) + if f.TokenEstimate != 0 { + t.Errorf("TokenEstimate: got %d, want 0", f.TokenEstimate) + } + if f.CodeBlockCount != 0 { + t.Errorf("CodeBlockCount: got %d, want 0", f.CodeBlockCount) + } + if f.RecentToolCalls != 0 { + t.Errorf("RecentToolCalls: got %d, want 0", f.RecentToolCalls) + } + if f.ConversationDepth != 0 { + t.Errorf("ConversationDepth: got %d, want 0", f.ConversationDepth) + } + if f.HasAttachments { + t.Error("HasAttachments: got true, want false") + } +} + +func TestExtractFeatures_TokenEstimate(t *testing.T) { + // 30 ASCII chars / 3 = 10 tokens + msg := strings.Repeat("a", 30) + f := ExtractFeatures(msg, nil) + if f.TokenEstimate != 10 { + t.Errorf("TokenEstimate: got %d, want 10", f.TokenEstimate) + } +} + +func TestExtractFeatures_TokenEstimate_CJK(t *testing.T) { + // 9 CJK runes / 3 = 3 tokens + msg := "你好世界你好世界你" // 9 runes + f := ExtractFeatures(msg, nil) + if f.TokenEstimate != 3 { + t.Errorf("CJK TokenEstimate: got %d, want 3", f.TokenEstimate) + } +} + +func TestExtractFeatures_CodeBlocks(t *testing.T) { + cases := []struct { + msg string + want int + }{ + {"no code here", 0}, + {"```go\nfmt.Println()\n```", 1}, + {"```python\npass\n```\n```js\nconsole.log()\n```", 2}, + {"```unclosed", 0}, // odd number of fences = 0 complete blocks + } + for _, tc := range cases { + f := ExtractFeatures(tc.msg, nil) + if f.CodeBlockCount != tc.want { + t.Errorf("msg=%q: CodeBlockCount got %d, want %d", tc.msg, f.CodeBlockCount, tc.want) + } + } +} + +func TestExtractFeatures_RecentToolCalls(t *testing.T) { + // History longer than lookbackWindow — only last lookbackWindow entries count. + history := make([]providers.Message, 10) + // Put 2 tool calls at positions 8 and 9 (within the last 6) + history[8] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}}} + history[9] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}} + // Position 3 is outside the lookback window and must NOT be counted + history[3] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "old_tool"}}} + + f := ExtractFeatures("test", history) + // 1 (position 8) + 2 (position 9) = 3 + if f.RecentToolCalls != 3 { + t.Errorf("RecentToolCalls: got %d, want 3", f.RecentToolCalls) + } +} + +func TestExtractFeatures_ConversationDepth(t *testing.T) { + history := make([]providers.Message, 7) + f := ExtractFeatures("msg", history) + if f.ConversationDepth != 7 { + t.Errorf("ConversationDepth: got %d, want 7", f.ConversationDepth) + } +} + +func TestExtractFeatures_HasAttachments_DataURI(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + {"plain text", false}, + {"here is an image: data:image/png;base64,abc123", true}, + {"audio: data:audio/mp3;base64,xyz", true}, + {"video: data:video/mp4;base64,xyz", true}, + } + for _, tc := range cases { + f := ExtractFeatures(tc.msg, nil) + if f.HasAttachments != tc.want { + t.Errorf("msg=%q: HasAttachments got %v, want %v", tc.msg, f.HasAttachments, tc.want) + } + } +} + +func TestExtractFeatures_HasAttachments_Extension(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + {"check out photo.jpg", true}, + {"see screenshot.png", true}, + {"listen to audio.mp3", true}, + {"watch clip.mp4", true}, + {"just a .go file", false}, + {"document.pdf", false}, // pdf is not in the media list + } + for _, tc := range cases { + f := ExtractFeatures(tc.msg, nil) + if f.HasAttachments != tc.want { + t.Errorf("msg=%q: HasAttachments got %v, want %v", tc.msg, f.HasAttachments, tc.want) + } + } +} + +// ── RuleClassifier ─────────────────────────────────────────────────────────── + +func TestRuleClassifier_ZeroFeatures(t *testing.T) { + c := &RuleClassifier{} + score := c.Score(Features{}) + if score != 0.0 { + t.Errorf("zero features: got %f, want 0.0", score) + } +} + +func TestRuleClassifier_AttachmentsHardGate(t *testing.T) { + c := &RuleClassifier{} + score := c.Score(Features{HasAttachments: true}) + if score != 1.0 { + t.Errorf("attachments: got %f, want 1.0", score) + } +} + +func TestRuleClassifier_CodeBlockAlone(t *testing.T) { + c := &RuleClassifier{} + // Code block alone = 0.40, above default threshold 0.35 + score := c.Score(Features{CodeBlockCount: 1}) + if score < 0.35 { + t.Errorf("code block: score %f is below default threshold 0.35", score) + } +} + +func TestRuleClassifier_LongMessage(t *testing.T) { + c := &RuleClassifier{} + // >200 tokens = 0.35, exactly at default threshold → heavy + score := c.Score(Features{TokenEstimate: 250}) + if score < 0.35 { + t.Errorf("long message: score %f is below default threshold 0.35", score) + } +} + +func TestRuleClassifier_MediumMessage(t *testing.T) { + c := &RuleClassifier{} + // 50-200 tokens = 0.15, below threshold → light + score := c.Score(Features{TokenEstimate: 100}) + if score >= 0.35 { + t.Errorf("medium message: score %f should be below default threshold 0.35", score) + } +} + +func TestRuleClassifier_ShortMessage(t *testing.T) { + c := &RuleClassifier{} + // <50 tokens, no other signals = 0.0 → light + score := c.Score(Features{TokenEstimate: 10}) + if score != 0.0 { + t.Errorf("short message: got %f, want 0.0", score) + } +} + +func TestRuleClassifier_ToolCallDensity(t *testing.T) { + c := &RuleClassifier{} + + scoreNone := c.Score(Features{RecentToolCalls: 0}) + scoreLow := c.Score(Features{RecentToolCalls: 2}) + scoreHigh := c.Score(Features{RecentToolCalls: 5}) + + if scoreNone != 0.0 { + t.Errorf("no tools: got %f, want 0.0", scoreNone) + } + if scoreLow <= scoreNone { + t.Errorf("low tools should score higher than none: %f vs %f", scoreLow, scoreNone) + } + if scoreHigh <= scoreLow { + t.Errorf("high tools should score higher than low: %f vs %f", scoreHigh, scoreLow) + } +} + +func TestRuleClassifier_DeepConversation(t *testing.T) { + c := &RuleClassifier{} + shallow := c.Score(Features{ConversationDepth: 5}) + deep := c.Score(Features{ConversationDepth: 15}) + if deep <= shallow { + t.Errorf("deep conversation should score higher: %f vs %f", deep, shallow) + } +} + +func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) { + c := &RuleClassifier{} + // Max all signals simultaneously + f := Features{ + TokenEstimate: 500, + CodeBlockCount: 3, + RecentToolCalls: 10, + ConversationDepth: 20, + } + score := c.Score(f) + if score > 1.0 { + t.Errorf("score %f exceeds 1.0", score) + } +} + +// ── Router ─────────────────────────────────────────────────────────────────── + +func TestRouter_DefaultThreshold(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash"}) + if r.Threshold() != defaultThreshold { + t.Errorf("default threshold: got %f, want %f", r.Threshold(), defaultThreshold) + } +} + +func TestRouter_NegativeThresholdFallsBackToDefault(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: -0.1}) + if r.Threshold() != defaultThreshold { + t.Errorf("negative threshold: got %f, want %f", r.Threshold(), defaultThreshold) + } +} + +func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + msg := "hi" + model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if !usedLight { + t.Error("simple message: expected light model to be selected") + } + if model != "gemini-flash" { + t.Errorf("simple message: model got %q, want %q", model, "gemini-flash") + } +} + +func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + msg := "```go\nfmt.Println(\"hello\")\n```" + model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if usedLight { + t.Error("code block: expected primary model to be selected") + } + if model != "claude-sonnet-4-6" { + t.Errorf("code block: model got %q, want %q", model, "claude-sonnet-4-6") + } +} + +func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + msg := "can you analyze this? data:image/png;base64,abc123" + model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if usedLight { + t.Error("attachment: expected primary model to be selected") + } + if model != "claude-sonnet-4-6" { + t.Errorf("attachment: model got %q, want %q", model, "claude-sonnet-4-6") + } +} + +func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + // >200 token estimate: 210 * 3 = 630 chars + msg := strings.Repeat("word ", 210) + model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if usedLight { + t.Error("long message: expected primary model to be selected") + } + if model != "claude-sonnet-4-6" { + t.Errorf("long message: model got %q, want %q", model, "claude-sonnet-4-6") + } +} + +func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { + // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. + // Routing is conservative: only promote to heavy when the signal is unambiguous. + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + history := []providers.Message{ + {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}}, + {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}}, + } + msg := "ok" + _, usedLight := r.SelectModel(msg, history, "claude-sonnet-4-6") + if !usedLight { + t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)") + } +} + +func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { + // Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) + history := []providers.Message{ + {Role: "assistant", ToolCalls: []providers.ToolCall{ + {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, + }}, + } + // ~55 tokens * 3 = 165 chars + msg := strings.Repeat("word ", 55) + _, usedLight := r.SelectModel(msg, history, "claude-sonnet-4-6") + if usedLight { + t.Error("tool chain + medium message: expected primary model (score >= 0.35)") + } +} + +func TestRouter_SelectModel_CustomThreshold(t *testing.T) { + // Very low threshold: even a short message triggers heavy model + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.05}) + msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05 + _, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if usedLight { + t.Error("low threshold: medium message should use primary model") + } +} + +func TestRouter_SelectModel_HighThreshold(t *testing.T) { + // Very high threshold: even code blocks route to light + r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99}) + msg := "```go\nfmt.Println()\n```" + _, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + if !usedLight { + t.Error("very high threshold: code block (0.40) should route to light model") + } +} + +func TestRouter_LightModel(t *testing.T) { + r := New(RouterConfig{LightModel: "my-fast-model", Threshold: 0.35}) + if r.LightModel() != "my-fast-model" { + t.Errorf("LightModel: got %q, want %q", r.LightModel(), "my-fast-model") + } +} + +// ── newWithClassifier (internal testing hook) ───────────────────────────────── + +type fixedScoreClassifier struct{ score float64 } + +func (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score } + +func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { + r := newWithClassifier( + RouterConfig{LightModel: "light", Threshold: 0.5}, + &fixedScoreClassifier{score: 0.2}, + ) + _, usedLight := r.SelectModel("anything", nil, "heavy") + if !usedLight { + t.Error("low score with custom classifier: expected light model") + } +} + +func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { + r := newWithClassifier( + RouterConfig{LightModel: "light", Threshold: 0.5}, + &fixedScoreClassifier{score: 0.8}, + ) + _, usedLight := r.SelectModel("anything", nil, "heavy") + if usedLight { + t.Error("high score with custom classifier: expected primary model") + } +} + +func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { + // score == threshold → primary (uses >= comparison) + r := newWithClassifier( + RouterConfig{LightModel: "light", Threshold: 0.5}, + &fixedScoreClassifier{score: 0.5}, + ) + _, usedLight := r.SelectModel("anything", nil, "heavy") + if usedLight { + t.Error("score == threshold: expected primary model (>= threshold → primary)") + } +} From 02e81923493712bd714fce8f63d08a79912bd97b Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Mon, 2 Mar 2026 22:42:52 +0800 Subject: [PATCH 21/73] feat(agent): wire model routing into the agent loop instance.go: - Add Router *routing.Router and LightCandidates []FallbackCandidate to AgentInstance. - At agent creation, when routing.enabled and light_model resolves successfully in model_list, pre-build the Router and resolve the light model candidates once. If the light model isn't in model_list, log a warning and disable routing for that agent gracefully. loop.go: - Add selectCandidates(agent, userMsg, history) helper. It calls Router.SelectModel and returns either agent.Candidates / agent.Model (primary tier) or agent.LightCandidates / light_model (light tier). Returns primary unchanged when routing is disabled. - In runLLMIteration, resolve (activeCandidates, activeModel) once before entering the tool-iteration loop. The model tier is sticky for the entire turn so a multi-step tool chain doesn't switch models mid-way. - Replace hard-coded agent.Candidates / agent.Model references in callLLM and the debug log with the resolved active values. The fallback chain and retry logic are untouched. When light_model returns an error the fallback chain handles escalation normally. --- pkg/agent/instance.go | 61 +++++++++++++++++++++++++++++++------------ pkg/agent/loop.go | 47 +++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index ed438059f..ec8871e30 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -34,6 +34,14 @@ type AgentInstance struct { Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate + + // Router is non-nil when model routing is configured and the light model + // was successfully resolved. It scores each incoming message and decides + // whether to route to LightCandidates or stay with Candidates. + Router *routing.Router + // LightCandidates holds the resolved provider candidates for the light model. + // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. + LightCandidates []providers.FallbackCandidate } // NewAgentInstance creates an agent instance from config. @@ -148,23 +156,44 @@ func NewAgentInstance( candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) + // Model routing setup: pre-resolve light model candidates at creation time + // to avoid repeated model_list lookups on every incoming message. + var router *routing.Router + var lightCandidates []providers.FallbackCandidate + if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { + lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} + resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) + if len(resolved) > 0 { + router = routing.New(routing.RouterConfig{ + LightModel: rc.LightModel, + Threshold: rc.Threshold, + }) + lightCandidates = resolved + } else { + log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q", + rc.LightModel, agentID) + } + } + 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, + Provider: provider, + Sessions: sessionsManager, + ContextBuilder: contextBuilder, + Tools: toolsRegistry, + Subagents: subagents, + SkillsFilter: skillsFilter, + Candidates: candidates, + Router: router, + LightCandidates: lightCandidates, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 00b0f096a..6df956627 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -625,6 +625,12 @@ func (al *AgentLoop) runLLMIteration( iteration := 0 var finalContent string + // Determine effective model tier for this conversation turn. + // selectCandidates evaluates routing once and the decision is sticky for + // all tool-follow-up iterations within the same turn so that a multi-step + // tool chain doesn't switch models mid-way through. + activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages) + for iteration < agent.MaxIterations { iteration++ @@ -643,7 +649,7 @@ func (al *AgentLoop) runLLMIteration( map[string]any{ "agent_id": agent.ID, "iteration": iteration, - "model": agent.Model, + "model": activeModel, "messages_count": len(messages), "tools_count": len(providerToolDefs), "max_tokens": agent.MaxTokens, @@ -659,13 +665,13 @@ func (al *AgentLoop) runLLMIteration( "tools_json": formatToolsForLog(providerToolDefs), }) - // Call LLM with fallback chain if candidates are configured. + // Call LLM with fallback chain if multiple candidates are configured. var response *providers.LLMResponse var err error callLLM := func() (*providers.LLMResponse, error) { - if len(agent.Candidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, + if len(activeCandidates) > 1 && al.fallback != nil { + fbResult, fbErr := al.fallback.Execute(ctx, activeCandidates, 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, @@ -684,7 +690,7 @@ func (al *AgentLoop) runLLMIteration( } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ + return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, "prompt_cache_key": agent.ID, @@ -934,6 +940,37 @@ func (al *AgentLoop) runLLMIteration( return finalContent, iteration, nil } +// selectCandidates returns the model candidates and resolved model name to use +// for a conversation turn. When model routing is configured and the incoming +// message scores below the complexity threshold, it returns the light model +// candidates instead of the primary ones. +// +// The returned (candidates, model) pair is used for all LLM calls within one +// turn — tool follow-up iterations use the same tier as the initial call so +// that a multi-step tool chain doesn't switch models mid-way. +func (al *AgentLoop) selectCandidates( + agent *AgentInstance, + userMsg string, + history []providers.Message, +) (candidates []providers.FallbackCandidate, model string) { + if agent.Router == nil || len(agent.LightCandidates) == 0 { + return agent.Candidates, agent.Model + } + + _, usedLight := agent.Router.SelectModel(userMsg, history, agent.Model) + if !usedLight { + return agent.Candidates, agent.Model + } + + logger.InfoCF("agent", "Model routing: light model selected", + map[string]any{ + "agent_id": agent.ID, + "light_model": agent.Router.LightModel(), + "threshold": agent.Router.Threshold(), + }) + return agent.LightCandidates, agent.Router.LightModel() +} + // updateToolContexts updates the context for tools that need channel/chatID info. func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) { // Use ContextualTool interface instead of type assertions From 09e68cb63bd2ee556adcc1f559dd0e8019b3af37 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Mon, 2 Mar 2026 23:11:45 +0800 Subject: [PATCH 22/73] fix(routing): resolve golines, gosmopolitan and misspell lint failures - classifier.go: s/honour/honor/ (American English per misspell) - router.go: break SelectModel signature across lines (golines) - router_test.go: break long Message literal (golines) - router_test.go: replace CJK string literal with rune slice so gosmopolitan does not flag the source file; behaviour is identical --- pkg/routing/classifier.go | 2 +- pkg/routing/router.go | 6 +++++- pkg/routing/router_test.go | 14 +++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg/routing/classifier.go b/pkg/routing/classifier.go index 761a6fdec..8cddaf069 100644 --- a/pkg/routing/classifier.go +++ b/pkg/routing/classifier.go @@ -71,7 +71,7 @@ func (c *RuleClassifier) Score(f Features) float64 { score += 0.10 } - // Cap at 1.0 to honour the [0, 1] contract even when multiple signals fire + // Cap at 1.0 to honor the [0, 1] contract even when multiple signals fire // simultaneously (e.g., long message + code block + tool chain = 1.10 raw). if score > 1.0 { score = 1.0 diff --git a/pkg/routing/router.go b/pkg/routing/router.go index d4f5218d3..78092b106 100644 --- a/pkg/routing/router.go +++ b/pkg/routing/router.go @@ -57,7 +57,11 @@ func newWithClassifier(cfg RouterConfig, c Classifier) *Router { // // The caller is responsible for resolving the returned model name into // provider candidates (see AgentInstance.LightCandidates). -func (r *Router) SelectModel(msg string, history []providers.Message, primaryModel string) (model string, usedLight bool) { +func (r *Router) SelectModel( + msg string, + history []providers.Message, + primaryModel string, +) (model string, usedLight bool) { features := ExtractFeatures(msg, history) score := r.classifier.Score(features) if score < r.cfg.Threshold { diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go index 168227638..267200c2e 100644 --- a/pkg/routing/router_test.go +++ b/pkg/routing/router_test.go @@ -38,8 +38,13 @@ func TestExtractFeatures_TokenEstimate(t *testing.T) { } func TestExtractFeatures_TokenEstimate_CJK(t *testing.T) { - // 9 CJK runes / 3 = 3 tokens - msg := "你好世界你好世界你" // 9 runes + // 9 CJK runes (U+4F60 U+597D U+4E16 U+754C × 2 + U+4F60) / 3 = 3 tokens. + // Using a rune slice literal avoids CJK string literals in source. + msg := string([]rune{ + 0x4F60, 0x597D, 0x4E16, 0x754C, + 0x4F60, 0x597D, 0x4E16, 0x754C, + 0x4F60, + }) f := ExtractFeatures(msg, nil) if f.TokenEstimate != 3 { t.Errorf("CJK TokenEstimate: got %d, want 3", f.TokenEstimate) @@ -69,7 +74,10 @@ func TestExtractFeatures_RecentToolCalls(t *testing.T) { history := make([]providers.Message, 10) // Put 2 tool calls at positions 8 and 9 (within the last 6) history[8] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}}} - history[9] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}} + history[9] = providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}, + } // Position 3 is outside the lookback window and must NOT be counted history[3] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "old_tool"}}} From a4546ffb8f208f0b4b9f69262e20b3754c884279 Mon Sep 17 00:00:00 2001 From: Kyle D Date: Fri, 27 Feb 2026 03:02:07 +0000 Subject: [PATCH 23/73] feat: add Avian as a named LLM provider Add Avian (https://avian.io) as an OpenAI-compatible provider with API base https://api.avian.io/v1 and AVIAN_API_KEY env var support. Models: deepseek/deepseek-v3.2, moonshotai/kimi-k2.5, z-ai/glm-5, minimax/minimax-m2.5. Supports chat completions, streaming, and function calling. Changes: - Add Avian to ProvidersConfig struct, IsEmpty(), HasProvidersConfig() - Add avian protocol to factory provider and default API base - Add avian case to legacy provider selection (factory.go) - Add avian migration rule for old config format - Add default model entries to ModelList (deepseek-v3.2, kimi-k2.5) - Add avian to example config - Update AllProviders test count from 18 to 19 --- config/config.example.json | 4 ++++ pkg/config/config.go | 4 +++- pkg/config/defaults.go | 14 ++++++++++++++ pkg/config/migration.go | 17 +++++++++++++++++ pkg/config/migration_test.go | 1 + pkg/providers/factory.go | 16 ++++++++++++++++ pkg/providers/factory_provider.go | 4 +++- 7 files changed, 58 insertions(+), 2 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index adae6f05c..1db97d0bb 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -222,6 +222,10 @@ "mistral": { "api_key": "", "api_base": "https://api.mistral.ai/v1" + }, + "avian": { + "api_key": "", + "api_base": "https://api.avian.io/v1" } }, "tools": { diff --git a/pkg/config/config.go b/pkg/config/config.go index cb2799bba..e50a5c3e8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -429,6 +429,7 @@ type ProvidersConfig struct { Antigravity ProviderConfig `json:"antigravity"` Qwen ProviderConfig `json:"qwen"` Mistral ProviderConfig `json:"mistral"` + Avian ProviderConfig `json:"avian"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -452,7 +453,8 @@ func (p ProvidersConfig) IsEmpty() bool { p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && - p.Mistral.APIKey == "" && p.Mistral.APIBase == "" + p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && + p.Avian.APIKey == "" && p.Avian.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9fc09c5f1..5472dd94a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -306,6 +306,20 @@ func DefaultConfig() *Config { APIKey: "", }, + // Avian - https://avian.io + { + ModelName: "deepseek-v3.2", + Model: "avian/deepseek/deepseek-v3.2", + APIBase: "https://api.avian.io/v1", + APIKey: "", + }, + { + ModelName: "kimi-k2.5", + Model: "avian/moonshotai/kimi-k2.5", + APIBase: "https://api.avian.io/v1", + APIKey: "", + }, + // VLLM (local) - http://localhost:8000 { ModelName: "local-model", diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 772f714fd..4a17dd6c9 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -373,6 +373,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { }, true }, }, + { + providerNames: []string{"avian"}, + protocol: "avian", + buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + if p.Avian.APIKey == "" && p.Avian.APIBase == "" { + return ModelConfig{}, false + } + return ModelConfig{ + ModelName: "avian", + Model: "avian/deepseek/deepseek-v3.2", + APIKey: p.Avian.APIKey, + APIBase: p.Avian.APIBase, + Proxy: p.Avian.Proxy, + RequestTimeout: p.Avian.RequestTimeout, + }, true + }, + }, } // Process each provider migration diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index e24e9fa1d..dc86beb41 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -160,6 +160,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { Antigravity: ProviderConfig{AuthMethod: "oauth"}, Qwen: ProviderConfig{APIKey: "key17"}, Mistral: ProviderConfig{APIKey: "key18"}, + Avian: ProviderConfig{APIKey: "key19"}, }, } diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index 5b3e42b9e..a0d09a835 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -181,6 +181,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { sel.model = "deepseek-chat" } } + case "avian": + if cfg.Providers.Avian.APIKey != "" { + sel.apiKey = cfg.Providers.Avian.APIKey + sel.apiBase = cfg.Providers.Avian.APIBase + sel.proxy = cfg.Providers.Avian.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.avian.io/v1" + } + } case "mistral": if cfg.Providers.Mistral.APIKey != "" { sel.apiKey = cfg.Providers.Mistral.APIKey @@ -300,6 +309,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if sel.apiBase == "" { sel.apiBase = "https://api.mistral.ai/v1" } + case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": + sel.apiKey = cfg.Providers.Avian.APIKey + sel.apiBase = cfg.Providers.Avian.APIBase + sel.proxy = cfg.Providers.Avian.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.avian.io/v1" + } case cfg.Providers.VLLM.APIBase != "": sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 155317a3b..c05fb0ad4 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "volcengine", "vllm", "qwen", "mistral": + "volcengine", "vllm", "qwen", "mistral", "avian": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -208,6 +208,8 @@ func getDefaultAPIBase(protocol string) string { return "http://localhost:8000/v1" case "mistral": return "https://api.mistral.ai/v1" + case "avian": + return "https://api.avian.io/v1" default: return "" } From 465819e1c66c74b38ab86e2358f0159d1c86027d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 09:13:20 +0800 Subject: [PATCH 24/73] feat(discord): support referenced/quoted messages in replies When a user replies to a message in Discord, the bot now reads m.ReferencedMessage and prepends its content to the incoming message as '[quoted message from Username]: content'. This gives the LLM full context of what message the user is replying to, enabling meaningful follow-up conversations. --- pkg/channels/discord/discord.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 1de910c83..0708afc69 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -338,6 +338,15 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content = c.stripBotMention(content) } + // Prepend referenced (quoted) message content if this is a reply + if m.MessageReference != nil && m.ReferencedMessage != nil { + refContent := m.ReferencedMessage.Content + if refContent != "" { + content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", + m.ReferencedMessage.Author.Username, refContent, content) + } + } + senderID := m.Author.ID mediaPaths := make([]string, 0, len(m.Attachments)) From 922604fc7eeab69fc0ec5d073dc51573871b47ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 09:14:18 +0800 Subject: [PATCH 25/73] feat(discord): resolve channel references and expand message links Add resolveDiscordRefs method that: 1. Resolves <#id> channel mentions to #channel-name by calling the Discord API to fetch channel info 2. Expands Discord message links (up to 3) by fetching the linked message content and appending it as '[linked message from User]: content' Applied to both quoted/referenced messages and the main message content for full context resolution. --- pkg/channels/discord/discord.go | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 0708afc69..371ec91ad 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" "sync" "time" @@ -342,10 +343,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if m.MessageReference != nil && m.ReferencedMessage != nil { refContent := m.ReferencedMessage.Content if refContent != "" { + refContent = c.resolveDiscordRefs(s, refContent) content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", m.ReferencedMessage.Author.Username, refContent, content) } } + content = c.resolveDiscordRefs(s, content) senderID := m.Author.ID @@ -517,6 +520,45 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { return nil } +// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and +// expands Discord message links to show the linked message content. +func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) string { + // 1. Resolve channel references: <#id> → #channel-name + channelRe := regexp.MustCompile(`<#(\d+)>`) + text = channelRe.ReplaceAllStringFunc(text, func(match string) string { + parts := channelRe.FindStringSubmatch(match) + if len(parts) < 2 { + return match + } + ch, err := s.Channel(parts[1]) + if err != nil { + return match + } + return "#" + ch.Name + }) + + // 2. Expand Discord message links (max 3) + msgLinkRe := regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`) + matches := msgLinkRe.FindAllStringSubmatch(text, 3) + for _, m := range matches { + if len(m) < 4 { + continue + } + channelID, messageID := m[2], m[3] + msg, err := s.ChannelMessage(channelID, messageID) + if err != nil || msg == nil || msg.Content == "" { + continue + } + author := "unknown" + if msg.Author != nil { + author = msg.Author.Username + } + text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content) + } + + return text +} + // 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 { From c3e029061b1d084394f23a3480430c24c5799b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 09:30:52 +0800 Subject: [PATCH 26/73] refactor(discord): self-review fixes for resolveDiscordRefs - Guard against nil ReferencedMessage.Author to prevent panic - Hoist regexp.MustCompile to package-level vars to avoid re-compilation on every handleMessage call - Both are defensive programming improvements --- pkg/channels/discord/discord.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 371ec91ad..31af566dc 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -27,6 +27,12 @@ const ( sendTimeout = 10 * time.Second ) +var ( + // Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call) + channelRefRe = regexp.MustCompile(`<#(\d+)>`) + msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`) +) + type DiscordChannel struct { *channels.BaseChannel session *discordgo.Session @@ -343,9 +349,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if m.MessageReference != nil && m.ReferencedMessage != nil { refContent := m.ReferencedMessage.Content if refContent != "" { + refAuthor := "unknown" + if m.ReferencedMessage.Author != nil { + refAuthor = m.ReferencedMessage.Author.Username + } refContent = c.resolveDiscordRefs(s, refContent) content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", - m.ReferencedMessage.Author.Username, refContent, content) + refAuthor, refContent, content) } } content = c.resolveDiscordRefs(s, content) @@ -524,9 +534,8 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { // expands Discord message links to show the linked message content. func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) string { // 1. Resolve channel references: <#id> → #channel-name - channelRe := regexp.MustCompile(`<#(\d+)>`) - text = channelRe.ReplaceAllStringFunc(text, func(match string) string { - parts := channelRe.FindStringSubmatch(match) + text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string { + parts := channelRefRe.FindStringSubmatch(match) if len(parts) < 2 { return match } @@ -538,7 +547,6 @@ func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) s }) // 2. Expand Discord message links (max 3) - msgLinkRe := regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`) matches := msgLinkRe.FindAllStringSubmatch(text, 3) for _, m := range matches { if len(m) < 4 { From 38263333edf231d16cf93ce534cb752a04c28137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 10:08:13 +0800 Subject: [PATCH 27/73] fix(discord): prevent cross-guild message leakage in link expansion Security fix: resolveDiscordRefs now takes a guildID parameter and skips message links pointing to a different guild, preventing the bot from leaking content across guilds. Also uses s.State.Channel() cache before falling back to API calls to reduce Discord API usage and rate limit risk. --- pkg/channels/discord/discord.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 31af566dc..57445a02b 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -353,12 +353,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if m.ReferencedMessage.Author != nil { refAuthor = m.ReferencedMessage.Author.Username } - refContent = c.resolveDiscordRefs(s, refContent) + refContent = c.resolveDiscordRefs(s, refContent, m.GuildID) content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", refAuthor, refContent, content) } } - content = c.resolveDiscordRefs(s, content) + content = c.resolveDiscordRefs(s, content, m.GuildID) senderID := m.Author.ID @@ -532,27 +532,35 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { // resolveDiscordRefs resolves channel references (<#id> → #channel-name) and // expands Discord message links to show the linked message content. -func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) string { +// Only links pointing to the same guild are expanded to prevent cross-guild leakage. +func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string { // 1. Resolve channel references: <#id> → #channel-name text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string { parts := channelRefRe.FindStringSubmatch(match) if len(parts) < 2 { return match } - ch, err := s.Channel(parts[1]) - if err != nil { - return match + // Prefer session state cache to avoid API calls + if ch, err := s.State.Channel(parts[1]); err == nil { + return "#" + ch.Name } - return "#" + ch.Name + if ch, err := s.Channel(parts[1]); err == nil { + return "#" + ch.Name + } + return match }) - // 2. Expand Discord message links (max 3) + // 2. Expand Discord message links (max 3, same guild only) matches := msgLinkRe.FindAllStringSubmatch(text, 3) for _, m := range matches { if len(m) < 4 { continue } - channelID, messageID := m[2], m[3] + linkGuildID, channelID, messageID := m[1], m[2], m[3] + // Security: only expand links from the same guild + if linkGuildID != guildID { + continue + } msg, err := s.ChannelMessage(channelID, messageID) if err != nil || msg == nil || msg.Content == "" { continue From 16209d1da926802f522ec7a5353bef6ba6156fd4 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Tue, 3 Mar 2026 18:45:25 -0800 Subject: [PATCH 28/73] 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 29/73] 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 30/73] =?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 e0616362fe65373906634f869fc4288ea82f411d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 15:10:10 +0800 Subject: [PATCH 31/73] fix(discord): prevent duplicate link expansion and add regex tests Address Copilot review feedback: - Move resolveDiscordRefs(content) before the referenced message concatenation to prevent message links in quoted replies from being expanded twice. - Add unit tests for channelRefRe and msgLinkRe regex patterns, covering valid/invalid inputs and the 3-link cap. --- pkg/channels/discord/discord.go | 5 +- pkg/channels/discord/discord_resolve_test.go | 98 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 pkg/channels/discord/discord_resolve_test.go diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 57445a02b..c3bcbff8d 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -345,6 +345,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content = c.stripBotMention(content) } + // Resolve Discord refs in main content before concatenation to avoid + // double-expanding links that appear in the referenced message. + content = c.resolveDiscordRefs(s, content, m.GuildID) + // Prepend referenced (quoted) message content if this is a reply if m.MessageReference != nil && m.ReferencedMessage != nil { refContent := m.ReferencedMessage.Content @@ -358,7 +362,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag refAuthor, refContent, content) } } - content = c.resolveDiscordRefs(s, content, m.GuildID) senderID := m.Author.ID diff --git a/pkg/channels/discord/discord_resolve_test.go b/pkg/channels/discord/discord_resolve_test.go new file mode 100644 index 000000000..4bc65cc18 --- /dev/null +++ b/pkg/channels/discord/discord_resolve_test.go @@ -0,0 +1,98 @@ +package discord + +import ( + "testing" +) + +func TestChannelRefRegex(t *testing.T) { + tests := []struct { + name string + input string + wantID string + wantOK bool + }{ + {"basic channel ref", "<#123456789>", "123456789", true}, + {"long id", "<#9876543210123456>", "9876543210123456", true}, + {"no match plain text", "hello world", "", false}, + {"no match partial", "<#>", "", false}, + {"no match letters", "<#abc>", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := channelRefRe.FindStringSubmatch(tt.input) + if tt.wantOK { + if len(matches) < 2 || matches[1] != tt.wantID { + t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID) + } + } else { + if len(matches) >= 2 { + t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches) + } + } + }) + } +} + +func TestMsgLinkRegex(t *testing.T) { + tests := []struct { + name string + input string + wantGuild string + wantChan string + wantMsg string + wantOK bool + }{ + { + "discord.com link", + "https://discord.com/channels/111/222/333", + "111", "222", "333", true, + }, + { + "discordapp.com link", + "https://discordapp.com/channels/111/222/333", + "111", "222", "333", true, + }, + { + "real world ids", + "check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please", + "9000000000000001", "9000000000000002", "9000000000000003", true, + }, + {"no match http", "http://discord.com/channels/1/2/3", "", "", "", false}, + {"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false}, + {"no match plain text", "hello world", "", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := msgLinkRe.FindStringSubmatch(tt.input) + if tt.wantOK { + if len(matches) < 4 { + t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s", + tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg) + } + if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg { + t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s", + tt.input, matches[1], matches[2], matches[3], + tt.wantGuild, tt.wantChan, tt.wantMsg) + } + } else { + if len(matches) >= 4 { + t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches) + } + } + }) + } +} + +func TestMsgLinkRegex_MultipleMatches(t *testing.T) { + input := "see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12" + matches := msgLinkRe.FindAllStringSubmatch(input, 3) + if len(matches) != 3 { + t.Fatalf("expected 3 matches (capped), got %d", len(matches)) + } + // Verify the 3rd match is 7/8/9 (not 10/11/12) + if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" { + t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2]) + } +} From 2a577f7a1d2aecb2eb50a0fb67cd95311b8891bf Mon Sep 17 00:00:00 2001 From: Meng Zhuo Date: Wed, 4 Mar 2026 17:05:57 +0800 Subject: [PATCH 32/73] 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 33/73] 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 34/73] 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 35/73] 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 36/73] 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 37/73] 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 +} From b9ee9b33f50dd460d0bb56d212d15c0c9fe04b03 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Wed, 4 Mar 2026 19:34:08 +0100 Subject: [PATCH 38/73] prevent audio as image url --- pkg/providers/openai_compat/provider.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index ff9109e96..1904ee153 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -323,12 +323,14 @@ func serializeMessages(messages []Message) []any { }) } for _, mediaURL := range m.Media { - parts = append(parts, map[string]any{ - "type": "image_url", - "image_url": map[string]any{ - "url": mediaURL, - }, - }) + if strings.HasPrefix(mediaURL, "data:image/") { + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": mediaURL, + }, + }) + } } msg := map[string]any{ From 0c97cb30d84dfeb6b273c64bf6fe15abeea7a57a Mon Sep 17 00:00:00 2001 From: Kyle D Date: Wed, 4 Mar 2026 20:14:43 +0000 Subject: [PATCH 39/73] fix: update provider count in migration test to include Avian The TestConvertProvidersToModelList_AllProviders test expected 19 providers but adding Avian brings the total to 20. Co-Authored-By: Claude Opus 4.6 --- pkg/config/migration_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index dc86beb41..67ad73db9 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -166,9 +166,9 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { result := ConvertProvidersToModelList(cfg) - // All 19 providers should be converted - if len(result) != 19 { - t.Errorf("len(result) = %d, want 19", len(result)) + // All 20 providers should be converted + if len(result) != 20 { + t.Errorf("len(result) = %d, want 20", len(result)) } } From de0f15d548f7a62521531423083e38a58b12fb1a Mon Sep 17 00:00:00 2001 From: Truong Vinh Tran Date: Wed, 4 Mar 2026 21:48:36 +0100 Subject: [PATCH 40/73] style: fix golines struct tag alignment in SearXNGConfig Co-Authored-By: Claude Opus 4.6 --- pkg/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 6cabddafc..8b0fd8098 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -548,8 +548,8 @@ type PerplexityConfig struct { } type SearXNGConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"` } From 204038ec6022bfb362ef83821bce3192cbca4638 Mon Sep 17 00:00:00 2001 From: Larry Koo Date: Thu, 5 Mar 2026 09:51:18 +0800 Subject: [PATCH 41/73] feat: add extended thinking support for Anthropic models (#1076) * feat: add extended thinking support for Anthropic models Support configurable thinking levels (off/low/medium/high/xhigh/adaptive) via `agents.defaults.thinking_level` config field. - "adaptive": uses Anthropic's adaptive thinking API (Claude 4.6+) - "low/medium/high/xhigh": uses budget_tokens (all thinking-capable models) - "off": disables thinking (default) API constraints handled: - Temperature cleared when thinking is enabled - budget_tokens clamped to max_tokens-1 - Thinking response blocks parsed into Reasoning field Relates to #645, #966 * fix: address PR review feedback for thinking support - Add ThinkingCapable interface for provider capability detection - Warn when thinking_level is set but provider doesn't support it - Warn when temperature is cleared due to thinking enabled - Adjust budget values per Anthropic best practices (medium=16K, xhigh=64K) - Add budget clamp warning and 80% threshold warning - Add parseResponse thinking block tests - Add thinking_level field to config.example.json * refactor: move ThinkingLevel from AgentDefaults to ModelConfig Thinking is a model-level capability, not a global agent property. Per-model config avoids silent ignoring on non-Anthropic providers and eliminates spurious warning logs in multi-provider setups. Addresses PR #1076 review feedback from @yinwm. --- config/config.example.json | 3 +- pkg/agent/instance.go | 8 + pkg/agent/loop.go | 34 ++-- pkg/agent/thinking.go | 39 +++++ pkg/agent/thinking_test.go | 35 ++++ pkg/config/config.go | 1 + pkg/providers/anthropic/provider.go | 79 +++++++++ pkg/providers/anthropic/thinking_test.go | 212 +++++++++++++++++++++++ pkg/providers/types.go | 7 + 9 files changed, 401 insertions(+), 17 deletions(-) create mode 100644 pkg/agent/thinking.go create mode 100644 pkg/agent/thinking_test.go create mode 100644 pkg/providers/anthropic/thinking_test.go diff --git a/config/config.example.json b/config/config.example.json index f6e7de12a..c59a39885 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -22,7 +22,8 @@ "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-ant-your-key", - "api_base": "https://api.anthropic.com/v1" + "api_base": "https://api.anthropic.com/v1", + "thinking_level": "high" }, { "model_name": "gemini", diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index ed25f537f..1e18b6f64 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -26,6 +26,7 @@ type AgentInstance struct { MaxIterations int MaxTokens int Temperature float64 + ThinkingLevel ThinkingLevel ContextWindow int SummarizeMessageThreshold int SummarizeTokenPercent int @@ -103,6 +104,12 @@ func NewAgentInstance( temperature = *defaults.Temperature } + var thinkingLevelStr string + if mc, err := cfg.GetModelConfig(model); err == nil { + thinkingLevelStr = mc.ThinkingLevel + } + thinkingLevel := parseThinkingLevel(thinkingLevelStr) + summarizeMessageThreshold := defaults.SummarizeMessageThreshold if summarizeMessageThreshold == 0 { summarizeMessageThreshold = 20 @@ -169,6 +176,7 @@ func NewAgentInstance( MaxIterations: maxIter, MaxTokens: maxTokens, Temperature: temperature, + ThinkingLevel: thinkingLevel, ContextWindow: maxTokens, SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7ce2a37a6..509f61099 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -834,23 +834,29 @@ func (al *AgentLoop) runLLMIteration( var response *providers.LLMResponse var err error + llmOpts := map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, + } + // parseThinkingLevel guarantees ThinkingOff for empty/unknown values, + // so checking != ThinkingOff is sufficient. + if agent.ThinkingLevel != ThinkingOff { + if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + llmOpts["thinking_level"] = string(agent.ThinkingLevel) + } else { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)}) + } + } + callLLM := func() (*providers.LLMResponse, error) { if len(agent.Candidates) > 1 && al.fallback != nil { 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, llmOpts) }, ) if fbErr != nil { @@ -866,11 +872,7 @@ func (al *AgentLoop) runLLMIteration( } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, - "prompt_cache_key": agent.ID, - }) + return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, llmOpts) } // Retry loop for context/token errors diff --git a/pkg/agent/thinking.go b/pkg/agent/thinking.go new file mode 100644 index 000000000..015b69282 --- /dev/null +++ b/pkg/agent/thinking.go @@ -0,0 +1,39 @@ +package agent + +import "strings" + +// ThinkingLevel controls how the provider sends thinking parameters. +// +// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+) +// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models) +// - "off": disables thinking +type ThinkingLevel string + +const ( + ThinkingOff ThinkingLevel = "off" + ThinkingLow ThinkingLevel = "low" + ThinkingMedium ThinkingLevel = "medium" + ThinkingHigh ThinkingLevel = "high" + ThinkingXHigh ThinkingLevel = "xhigh" + ThinkingAdaptive ThinkingLevel = "adaptive" +) + +// parseThinkingLevel normalizes a config string to a ThinkingLevel. +// Case-insensitive and whitespace-tolerant for user-facing config values. +// Returns ThinkingOff for unknown or empty values. +func parseThinkingLevel(level string) ThinkingLevel { + switch strings.ToLower(strings.TrimSpace(level)) { + case "adaptive": + return ThinkingAdaptive + case "low": + return ThinkingLow + case "medium": + return ThinkingMedium + case "high": + return ThinkingHigh + case "xhigh": + return ThinkingXHigh + default: + return ThinkingOff + } +} diff --git a/pkg/agent/thinking_test.go b/pkg/agent/thinking_test.go new file mode 100644 index 000000000..be3a68c33 --- /dev/null +++ b/pkg/agent/thinking_test.go @@ -0,0 +1,35 @@ +package agent + +import "testing" + +func TestParseThinkingLevel(t *testing.T) { + tests := []struct { + name string + input string + want ThinkingLevel + }{ + {"off", "off", ThinkingOff}, + {"empty", "", ThinkingOff}, + {"low", "low", ThinkingLow}, + {"medium", "medium", ThinkingMedium}, + {"high", "high", ThinkingHigh}, + {"xhigh", "xhigh", ThinkingXHigh}, + {"adaptive", "adaptive", ThinkingAdaptive}, + {"unknown", "unknown", ThinkingOff}, + // Case-insensitive and whitespace-tolerant + {"upper_Medium", "Medium", ThinkingMedium}, + {"upper_HIGH", "HIGH", ThinkingHigh}, + {"mixed_Adaptive", "Adaptive", ThinkingAdaptive}, + {"leading_space", " high", ThinkingHigh}, + {"trailing_space", "low ", ThinkingLow}, + {"both_spaces", " medium ", ThinkingMedium}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseThinkingLevel(tt.input); got != tt.want { + t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7165df0d0..3cfebf5e8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -507,6 +507,7 @@ type ModelConfig struct { RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive } // Validate checks if the ModelConfig has all required fields. diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 1bb15f771..1b250b9b4 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -31,6 +31,9 @@ type Provider struct { baseURL string } +// SupportsThinking implements providers.ThinkingCapable. +func (p *Provider) SupportsThinking() bool { return true } + func NewProvider(token string) *Provider { return NewProviderWithBaseURL(token, "") } @@ -182,9 +185,80 @@ func buildParams( params.Tools = translateTools(tools) } + // Extended Thinking / Adaptive Thinking + // The thinking_level value directly determines the API parameter format: + // "adaptive" → {thinking: {type: "adaptive"}} + output_config.effort + // "low/medium/high/xhigh" → {thinking: {type: "enabled", budget_tokens: N}} + if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" { + applyThinkingConfig(¶ms, level) + } + return params, nil } +// applyThinkingConfig sets thinking parameters based on the level value. +// "adaptive" uses the adaptive thinking API (Claude 4.6+). +// All other levels use budget_tokens which is universally supported. +// +// Anthropic API constraint: temperature must not be set when thinking is enabled. +// budget_tokens must be strictly less than max_tokens. +func applyThinkingConfig(params *anthropic.MessageNewParams, level string) { + // Anthropic API rejects requests with temperature set alongside thinking. + // Reset to zero value (omitted from JSON serialization). + if params.Temperature.Valid() { + log.Printf("anthropic: temperature cleared because thinking is enabled (level=%s)", level) + } + params.Temperature = anthropic.MessageNewParams{}.Temperature + + if level == "adaptive" { + adaptive := anthropic.NewThinkingConfigAdaptiveParam() + params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive} + params.OutputConfig = anthropic.OutputConfigParam{ + Effort: anthropic.OutputConfigEffortHigh, + } + return + } + + budget := int64(levelToBudget(level)) + if budget <= 0 { + return + } + + // budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting. + if budget >= params.MaxTokens { + log.Printf("anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)", budget, params.MaxTokens-1) + budget = params.MaxTokens - 1 + } else if budget > params.MaxTokens*80/100 { + log.Printf("anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated", + budget, params.MaxTokens) + } + params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget) +} + +// levelToBudget maps a thinking level to budget_tokens. +// Values are based on Anthropic's recommendations and community best practices: +// +// low = 4,096 — simple reasoning, quick debugging (Claude Code "think") +// medium = 16,384 — Anthropic recommended sweet spot for most tasks +// high = 32,000 — complex architecture, deep analysis (diminishing returns above this) +// xhigh = 64,000 — extreme reasoning, research problems, benchmarks +// +// Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens. +func levelToBudget(level string) int { + switch level { + case "low": + return 4096 + case "medium": + return 16384 + case "high": + return 32000 + case "xhigh": + return 64000 + default: + return 0 + } +} + func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { result := make([]anthropic.ToolUnionParam, 0, len(tools)) for _, t := range tools { @@ -213,10 +287,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { func parseResponse(resp *anthropic.Message) *LLMResponse { var content strings.Builder + var reasoning strings.Builder var toolCalls []ToolCall for _, block := range resp.Content { switch block.Type { + case "thinking": + tb := block.AsThinking() + reasoning.WriteString(tb.Thinking) case "text": tb := block.AsText() content.WriteString(tb.Text) @@ -247,6 +325,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { return &LLMResponse{ Content: content.String(), + Reasoning: reasoning.String(), ToolCalls: toolCalls, FinishReason: finishReason, Usage: &UsageInfo{ diff --git a/pkg/providers/anthropic/thinking_test.go b/pkg/providers/anthropic/thinking_test.go new file mode 100644 index 000000000..e69a3869e --- /dev/null +++ b/pkg/providers/anthropic/thinking_test.go @@ -0,0 +1,212 @@ +package anthropicprovider + +import ( + "encoding/json" + "testing" + + "github.com/anthropics/anthropic-sdk-go" +) + +func TestApplyThinkingConfig_Adaptive(t *testing.T) { + params := anthropic.MessageNewParams{ + MaxTokens: 16000, + Temperature: anthropic.Float(0.7), + } + applyThinkingConfig(¶ms, "adaptive") + + if params.Thinking.OfAdaptive == nil { + t.Fatal("expected adaptive thinking") + } + if params.Thinking.OfEnabled != nil { + t.Error("should not set enabled thinking in adaptive mode") + } + if params.OutputConfig.Effort != anthropic.OutputConfigEffortHigh { + t.Errorf("effort = %q, want %q", params.OutputConfig.Effort, anthropic.OutputConfigEffortHigh) + } + if params.Temperature.Valid() { + t.Error("temperature should be cleared when thinking is enabled") + } +} + +func TestApplyThinkingConfig_BudgetLevels(t *testing.T) { + tests := []struct { + level string + wantBudget int64 + }{ + {"low", 4096}, + {"medium", 16384}, + {"high", 32000}, + {"xhigh", 64000}, + } + + for _, tt := range tests { + t.Run(tt.level, func(t *testing.T) { + params := anthropic.MessageNewParams{ + MaxTokens: 200000, + Temperature: anthropic.Float(0.5), + } + applyThinkingConfig(¶ms, tt.level) + + if params.Thinking.OfEnabled == nil { + t.Fatal("expected enabled thinking") + } + if params.Thinking.OfAdaptive != nil { + t.Error("should not set adaptive thinking") + } + if params.Thinking.OfEnabled.BudgetTokens != tt.wantBudget { + t.Errorf("budget_tokens = %d, want %d", params.Thinking.OfEnabled.BudgetTokens, tt.wantBudget) + } + if params.OutputConfig.Effort != "" { + t.Errorf("effort = %q, want empty", params.OutputConfig.Effort) + } + if params.Temperature.Valid() { + t.Error("temperature should be cleared when thinking is enabled") + } + }) + } +} + +func TestApplyThinkingConfig_BudgetClamp(t *testing.T) { + // budget_tokens must be < max_tokens; clamp budget down to respect user's max_tokens. + params := anthropic.MessageNewParams{MaxTokens: 4096} + applyThinkingConfig(¶ms, "high") // budget=32000 > maxTokens=4096 + + if params.Thinking.OfEnabled == nil { + t.Fatal("expected enabled thinking") + } + if params.Thinking.OfEnabled.BudgetTokens != 4095 { + t.Errorf("budget_tokens = %d, want 4095 (maxTokens-1)", params.Thinking.OfEnabled.BudgetTokens) + } + if params.MaxTokens != 4096 { + t.Errorf("max_tokens should not be modified, got %d", params.MaxTokens) + } +} + +func TestApplyThinkingConfig_UnknownLevel(t *testing.T) { + params := anthropic.MessageNewParams{MaxTokens: 16000} + applyThinkingConfig(¶ms, "unknown") + + if params.Thinking.OfEnabled != nil { + t.Error("should not set enabled thinking for unknown level") + } + if params.Thinking.OfAdaptive != nil { + t.Error("should not set adaptive thinking for unknown level") + } +} + +func TestLevelToBudget(t *testing.T) { + tests := []struct { + name string + level string + want int + }{ + {"low", "low", 4096}, + {"medium", "medium", 16384}, + {"high", "high", 32000}, + {"xhigh", "xhigh", 64000}, + {"off", "off", 0}, + {"empty", "", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := levelToBudget(tt.level); got != tt.want { + t.Errorf("levelToBudget(%q) = %d, want %d", tt.level, got, tt.want) + } + }) + } +} + +func TestBuildParams_ThinkingClearsTemperature(t *testing.T) { + msgs := []Message{{Role: "user", Content: "hello"}} + opts := map[string]any{ + "max_tokens": 200000, + "temperature": 0.8, + "thinking_level": "medium", + } + + params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts) + if err != nil { + t.Fatal(err) + } + + if params.Temperature.Valid() { + t.Error("temperature should be cleared when thinking_level is set") + } + if params.Thinking.OfEnabled == nil { + t.Fatal("expected enabled thinking") + } + if params.Thinking.OfEnabled.BudgetTokens != 16384 { + t.Errorf("budget_tokens = %d, want 16384", params.Thinking.OfEnabled.BudgetTokens) + } +} + +// unmarshalBlocks constructs []ContentBlockUnion via JSON round-trip so that +// the internal JSON.raw field is populated (required by AsText/AsThinking). +func unmarshalBlocks(t *testing.T, jsonStr string) []anthropic.ContentBlockUnion { + t.Helper() + var blocks []anthropic.ContentBlockUnion + if err := json.Unmarshal([]byte(jsonStr), &blocks); err != nil { + t.Fatalf("unmarshalBlocks: %v", err) + } + return blocks +} + +func TestParseResponse_ThinkingBlock(t *testing.T) { + resp := &anthropic.Message{ + Content: unmarshalBlocks(t, `[ + {"type":"thinking","thinking":"Let me reason step by step...","signature":"sig"}, + {"type":"text","text":"The answer is 42."} + ]`), + StopReason: anthropic.StopReasonEndTurn, + } + + result := parseResponse(resp) + + if result.Reasoning != "Let me reason step by step..." { + t.Errorf("Reasoning = %q, want thinking content", result.Reasoning) + } + if result.Content != "The answer is 42." { + t.Errorf("Content = %q, want text content", result.Content) + } + if result.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want stop", result.FinishReason) + } +} + +func TestParseResponse_NoThinkingBlock(t *testing.T) { + resp := &anthropic.Message{ + Content: unmarshalBlocks(t, `[ + {"type":"text","text":"Just a normal response."} + ]`), + StopReason: anthropic.StopReasonEndTurn, + } + + result := parseResponse(resp) + + if result.Reasoning != "" { + t.Errorf("Reasoning = %q, want empty", result.Reasoning) + } + if result.Content != "Just a normal response." { + t.Errorf("Content = %q, want text content", result.Content) + } +} + +func TestBuildParams_NoThinkingKeepsTemperature(t *testing.T) { + msgs := []Message{{Role: "user", Content: "hello"}} + opts := map[string]any{ + "temperature": 0.8, + } + + params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts) + if err != nil { + t.Fatal(err) + } + + if !params.Temperature.Valid() { + t.Error("temperature should be preserved when thinking is not set") + } + if params.Temperature.Value != 0.8 { + t.Errorf("temperature = %f, want 0.8", params.Temperature.Value) + } +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f0c168bc6..68bbd1e65 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -37,6 +37,13 @@ type StatefulProvider interface { Close() } +// ThinkingCapable is an optional interface for providers that support +// extended thinking (e.g. Anthropic). Used by the agent loop to warn +// when thinking_level is configured but the active provider cannot use it. +type ThinkingCapable interface { + SupportsThinking() bool +} + // FailoverReason classifies why an LLM request failed for fallback decisions. type FailoverReason string From aef1e8e8c489f427558d2004b1aecae68808d77a Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Thu, 5 Mar 2026 02:57:33 +0100 Subject: [PATCH 42/73] fix: eliminate data races on shared tool instances (#1080) * fix: eliminate data races on shared tool instances Signed-off-by: Boris Bliznioukov * fix: remove unused indirect dependency on github.com/gdamore/tcell/v2 Signed-off-by: Boris Bliznioukov * fix: reviewer comments improve context handling for tool execution and ensure defaults for non-conversation callers Signed-off-by: Boris Bliznioukov --------- Signed-off-by: Boris Bliznioukov --- go.mod | 1 - pkg/agent/loop.go | 45 +++++------------ pkg/agent/loop_test.go | 66 +++++-------------------- pkg/tools/base.go | 88 +++++++++++++++++++-------------- pkg/tools/cron.go | 22 ++------- pkg/tools/message.go | 18 +++---- pkg/tools/message_test.go | 17 +++---- pkg/tools/registry.go | 28 ++++++----- pkg/tools/registry_test.go | 54 +++++++++++--------- pkg/tools/spawn.go | 44 ++++++++++------- pkg/tools/subagent.go | 26 +++++----- pkg/tools/subagent_tool_test.go | 24 ++------- 12 files changed, 181 insertions(+), 252 deletions(-) diff --git a/go.mod b/go.mod index c1172937c..238bd405c 100644 --- a/go.mod +++ b/go.mod @@ -37,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/h2non/filetype v1.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 509f61099..263eeb4dd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -543,8 +543,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) // Reset message-tool state for this round so we don't skip publishing due to a previous round. if tool, ok := agent.Tools.Get("message"); ok { - if mt, ok := tool.(tools.ContextualTool); ok { - mt.SetContext(msg.Channel, msg.ChatID) + if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { + resetter.ResetSentInRound() } } @@ -659,10 +659,7 @@ func (al *AgentLoop) runAgentLoop( } } - // 1. Update tool contexts - al.updateToolContexts(agent, opts.Channel, opts.ChatID) - - // 2. Build messages (skip history for heartbeat) + // 1. Build messages (skip history for heartbeat) var history []providers.Message var summary string if !opts.NoHistory { @@ -682,10 +679,10 @@ func (al *AgentLoop) runAgentLoop( maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - // 3. Save user message to session + // 2. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) - // 4. Run LLM iteration loop + // 3. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { return "", err @@ -694,21 +691,21 @@ func (al *AgentLoop) runAgentLoop( // If last tool had ForUser content and we already sent it, we might not need to send final response // This is controlled by the tool's Silent flag and ForUser content - // 5. Handle empty response + // 4. Handle empty response if finalContent == "" { finalContent = opts.DefaultResponse } - // 6. Save final assistant message to session + // 5. Save final assistant message to session agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.Save(opts.SessionKey) - // 7. Optional: summarization + // 6. Optional: summarization if opts.EnableSummary { al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) } - // 8. Optional: send response via bus + // 7. Optional: send response via bus if opts.SendResponse { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, @@ -717,7 +714,7 @@ func (al *AgentLoop) runAgentLoop( }) } - // 9. Log response + // 8. Log response responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), map[string]any{ @@ -1059,7 +1056,7 @@ func (al *AgentLoop) runLLMIteration( "iteration": iteration, }) - // Create async callback for tools that implement AsyncTool + // Create async callback for tools that implement AsyncExecutor asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { if !result.Silent && result.ForUser != "" { logger.InfoCF("agent", "Async tool completed, agent will handle notification", @@ -1141,26 +1138,6 @@ func (al *AgentLoop) runLLMIteration( return finalContent, iteration, nil } -// updateToolContexts updates the context for tools that need channel/chatID info. -func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) { - // Use ContextualTool interface instead of type assertions - if tool, ok := agent.Tools.Get("message"); ok { - if mt, ok := tool.(tools.ContextualTool); ok { - mt.SetContext(channel, chatID) - } - } - if tool, ok := agent.Tools.Get("spawn"); ok { - if st, ok := tool.(tools.ContextualTool); ok { - st.SetContext(channel, chatID) - } - } - if tool, ok := agent.Tools.Get("subagent"); ok { - if st, ok := tool.(tools.ContextualTool); ok { - st.SetContext(channel, chatID) - } - } -} - // maybeSummarize triggers summarization if the session history exceeds thresholds. func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 023286f02..4ab6b4542 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -164,35 +164,21 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { } } -// TestToolContext_Updates verifies tool context is updated with channel/chatID +// TestToolContext_Updates verifies tool context helpers work correctly func TestToolContext_Updates(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) + ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42") - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, + if got := tools.ToolChannel(ctx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := tools.ToolChatID(ctx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) } - msgBus := bus.NewMessageBus() - provider := &simpleMockProvider{response: "OK"} - _ = NewAgentLoop(cfg, msgBus, provider) - - // Verify that ContextualTool interface is defined and can be implemented - // This test validates the interface contract exists - ctxTool := &mockContextualTool{} - - // Verify the tool implements the interface correctly - var _ tools.ContextualTool = ctxTool + // Empty context returns empty strings + if got := tools.ToolChannel(context.Background()); got != "" { + t.Errorf("expected empty channel from bare context, got %q", got) + } } // TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved @@ -359,36 +345,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool return tools.SilentResult("Custom tool executed") } -// mockContextualTool tracks context updates -type mockContextualTool struct { - lastChannel string - lastChatID string -} - -func (m *mockContextualTool) Name() string { - return "mock_contextual" -} - -func (m *mockContextualTool) Description() string { - return "Mock contextual tool" -} - -func (m *mockContextualTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{}, - } -} - -func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { - return tools.SilentResult("Contextual tool executed") -} - -func (m *mockContextualTool) SetContext(channel, chatID string) { - m.lastChannel = channel - m.lastChatID = chatID -} - // testHelper executes a message and returns the response type testHelper struct { al *AgentLoop diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 770d8cb04..ec743e164 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -10,11 +10,38 @@ type Tool interface { Execute(ctx context.Context, args map[string]any) *ToolResult } -// ContextualTool is an optional interface that tools can implement -// to receive the current message context (channel, chatID) -type ContextualTool interface { - Tool - SetContext(channel, chatID string) +// --- Request-scoped tool context (channel / chatID) --- +// +// Carried via context.Value so that concurrent tool calls each receive +// their own immutable copy — no mutable state on singleton tool instances. +// +// Keys are unexported pointer-typed vars — guaranteed collision-free, +// and only accessible through the helper functions below. + +type toolCtxKey struct{ name string } + +var ( + ctxKeyChannel = &toolCtxKey{"channel"} + ctxKeyChatID = &toolCtxKey{"chatID"} +) + +// WithToolContext returns a child context carrying channel and chatID. +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + ctx = context.WithValue(ctx, ctxKeyChannel, channel) + ctx = context.WithValue(ctx, ctxKeyChatID, chatID) + return ctx +} + +// ToolChannel extracts the channel from ctx, or "" if unset. +func ToolChannel(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyChannel).(string) + return v +} + +// ToolChatID extracts the chatID from ctx, or "" if unset. +func ToolChatID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyChatID).(string) + return v } // AsyncCallback is a function type that async tools use to notify completion. @@ -22,51 +49,36 @@ type ContextualTool interface { // // The ctx parameter allows the callback to be canceled if the agent is shutting down. // The result parameter contains the tool's execution result. -// -// Example usage in an async tool: -// -// func (t *MyAsyncTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { -// // Start async work in background -// go func() { -// result := doAsyncWork() -// if t.callback != nil { -// t.callback(ctx, result) -// } -// }() -// return AsyncResult("Async task started") -// } type AsyncCallback func(ctx context.Context, result *ToolResult) -// AsyncTool is an optional interface that tools can implement to support +// AsyncExecutor is an optional interface that tools can implement to support // asynchronous execution with completion callbacks. // -// Async tools return immediately with an AsyncResult, then notify completion -// via the callback set by SetCallback. +// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor +// receives the callback as a parameter of ExecuteAsync. This eliminates the +// data race where concurrent calls could overwrite each other's callbacks +// on a shared tool instance. // // This is useful for: -// - Long-running operations that shouldn't block the agent loop -// - Subagent spawns that complete independently -// - Background tasks that need to report results later +// - Long-running operations that shouldn't block the agent loop +// - Subagent spawns that complete independently +// - Background tasks that need to report results later // // Example: // -// type SpawnTool struct { -// callback AsyncCallback -// } -// -// func (t *SpawnTool) SetCallback(cb AsyncCallback) { -// t.callback = cb -// } -// -// func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { -// go t.runSubagent(ctx, args) +// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { +// go func() { +// result := t.runSubagent(ctx, args) +// if cb != nil { cb(ctx, result) } +// }() // return AsyncResult("Subagent spawned, will report back") // } -type AsyncTool interface { +type AsyncExecutor interface { Tool - // SetCallback registers a callback function to be invoked when the async operation completes. - // The callback will be called from a goroutine and should handle thread-safety if needed. - SetCallback(cb AsyncCallback) + // ExecuteAsync runs the tool asynchronously. The callback cb will be + // invoked (possibly from another goroutine) when the async operation + // completes. cb is guaranteed to be non-nil by the caller (registry). + ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult } func ToolToSchema(tool Tool) map[string]any { diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6888d1326..31ac9ab88 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" @@ -24,9 +23,6 @@ type CronTool struct { executor JobExecutor msgBus *bus.MessageBus execTool *ExecTool - channel string - chatID string - mu sync.RWMutex } // NewCronTool creates a new CronTool @@ -102,14 +98,6 @@ func (t *CronTool) Parameters() map[string]any { } } -// SetContext sets the current session context for job creation -func (t *CronTool) SetContext(channel, chatID string) { - t.mu.Lock() - defer t.mu.Unlock() - t.channel = channel - t.chatID = chatID -} - // Execute runs the tool with the given arguments func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { action, ok := args["action"].(string) @@ -119,7 +107,7 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult switch action { case "add": - return t.addJob(args) + return t.addJob(ctx, args) case "list": return t.listJobs() case "remove": @@ -133,11 +121,9 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } -func (t *CronTool) addJob(args map[string]any) *ToolResult { - t.mu.RLock() - channel := t.channel - chatID := t.chatID - t.mu.RUnlock() +func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult { + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) if channel == "" || chatID == "" { return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") diff --git a/pkg/tools/message.go b/pkg/tools/message.go index d1e4a373e..438ceeddd 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -9,10 +9,8 @@ import ( type SendCallback func(channel, chatID, content string) error type MessageTool struct { - sendCallback SendCallback - defaultChannel string - defaultChatID string - sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round + sendCallback SendCallback + sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round } func NewMessageTool() *MessageTool { @@ -48,10 +46,10 @@ func (t *MessageTool) Parameters() map[string]any { } } -func (t *MessageTool) SetContext(channel, chatID string) { - t.defaultChannel = channel - t.defaultChatID = chatID - t.sentInRound.Store(false) // Reset send tracking for new processing round +// ResetSentInRound resets the per-round send tracker. +// Called by the agent loop at the start of each inbound message processing round. +func (t *MessageTool) ResetSentInRound() { + t.sentInRound.Store(false) } // HasSentInRound returns true if the message tool sent a message during the current round. @@ -73,10 +71,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes chatID, _ := args["chat_id"].(string) if channel == "" { - channel = t.defaultChannel + channel = ToolChannel(ctx) } if chatID == "" { - chatID = t.defaultChatID + chatID = ToolChatID(ctx) } if channel == "" || chatID == "" { diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 717c1117b..05630972e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -8,7 +8,6 @@ import ( func TestMessageTool_Execute_Success(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") var sentChannel, sentChatID, sentContent string tool.SetSendCallback(func(channel, chatID, content string) error { @@ -18,7 +17,7 @@ func TestMessageTool_Execute_Success(t *testing.T) { return nil }) - ctx := context.Background() + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Hello, world!", } @@ -60,7 +59,6 @@ func TestMessageTool_Execute_Success(t *testing.T) { func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { tool := NewMessageTool() - tool.SetContext("default-channel", "default-chat-id") var sentChannel, sentChatID string tool.SetSendCallback(func(channel, chatID, content string) error { @@ -69,7 +67,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { return nil }) - ctx := context.Background() + ctx := WithToolContext(context.Background(), "default-channel", "default-chat-id") args := map[string]any{ "content": "Test message", "channel": "custom-channel", @@ -96,14 +94,13 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { func TestMessageTool_Execute_SendFailure(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") sendErr := errors.New("network error") tool.SetSendCallback(func(channel, chatID, content string) error { return sendErr }) - ctx := context.Background() + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Test message", } @@ -133,9 +130,8 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { func TestMessageTool_Execute_MissingContent(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") - ctx := context.Background() + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{} // content missing result := tool.Execute(ctx, args) @@ -151,7 +147,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) { func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { tool := NewMessageTool() - // No SetContext called, so defaultChannel and defaultChatID are empty + // No WithToolContext — channel/chatID are empty tool.SetSendCallback(func(channel, chatID, content string) error { return nil @@ -175,10 +171,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { func TestMessageTool_Execute_NotConfigured(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") // No SetSendCallback called - ctx := context.Background() + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Test message", } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 0ba983e02..ca8436c67 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -45,8 +45,9 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string } // ExecuteWithContext executes a tool with channel/chatID context and optional async callback. -// If the tool implements AsyncTool and a non-nil callback is provided, -// the callback will be set on the tool before execution. +// If the tool implements AsyncExecutor and a non-nil callback is provided, +// ExecuteAsync is called instead of Execute — the callback is a parameter, +// never stored as mutable state on the tool. func (r *ToolRegistry) ExecuteWithContext( ctx context.Context, name string, @@ -69,22 +70,23 @@ func (r *ToolRegistry) ExecuteWithContext( return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) } - // If tool implements ContextualTool, set context - if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" { - contextualTool.SetContext(channel, chatID) - } + // Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx). + // Always inject — tools validate what they require. + ctx = WithToolContext(ctx, channel, chatID) - // If tool implements AsyncTool and callback is provided, set callback - if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { - asyncTool.SetCallback(asyncCallback) - logger.DebugCF("tool", "Async callback injected", + // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync. + // The callback is a call parameter, not mutable state on the tool instance. + var result *ToolResult + start := time.Now() + if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { + logger.DebugCF("tool", "Executing async tool via ExecuteAsync", map[string]any{ "tool": name, }) + result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) + } else { + result = tool.Execute(ctx, args) } - - start := time.Now() - result := tool.Execute(ctx, args) duration := time.Since(start) // Log based on result type diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 8fe88ca78..92d7d5abd 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -25,24 +25,24 @@ func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolRes return m.result } -type mockCtxTool struct { +type mockContextAwareTool struct { mockRegistryTool - channel string - chatID string + lastCtx context.Context } -func (m *mockCtxTool) SetContext(channel, chatID string) { - m.channel = channel - m.chatID = chatID +func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult { + m.lastCtx = ctx + return m.result } type mockAsyncRegistryTool struct { mockRegistryTool - cb AsyncCallback + lastCB AsyncCallback } -func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) { - m.cb = cb +func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult { + m.lastCB = cb + return m.result } // --- helpers --- @@ -136,34 +136,44 @@ func TestToolRegistry_Execute_NotFound(t *testing.T) { } } -func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) { +func TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) { r := NewToolRegistry() - ct := &mockCtxTool{ + ct := &mockContextAwareTool{ mockRegistryTool: *newMockTool("ctx_tool", "needs context"), } r.Register(ct) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil) - if ct.channel != "telegram" { - t.Errorf("expected channel 'telegram', got %q", ct.channel) + if ct.lastCtx == nil { + t.Fatal("expected Execute to be called") } - if ct.chatID != "chat-42" { - t.Errorf("expected chatID 'chat-42', got %q", ct.chatID) + if got := ToolChannel(ct.lastCtx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := ToolChatID(ct.lastCtx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) } } -func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) { +func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) { r := NewToolRegistry() - ct := &mockCtxTool{ + ct := &mockContextAwareTool{ mockRegistryTool: *newMockTool("ctx_tool", "needs context"), } r.Register(ct) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) - if ct.channel != "" || ct.chatID != "" { - t.Error("SetContext should not be called with empty channel/chatID") + if ct.lastCtx == nil { + t.Fatal("expected Execute to be called") + } + // Empty values are still injected; tools decide what to do with them. + if got := ToolChannel(ct.lastCtx); got != "" { + t.Errorf("expected empty channel, got %q", got) + } + if got := ToolChatID(ct.lastCtx); got != "" { + t.Errorf("expected empty chatID, got %q", got) } } @@ -179,14 +189,14 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { cb := func(_ context.Context, _ *ToolResult) { called = true } result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb) - if at.cb == nil { - t.Error("expected SetCallback to have been called") + if at.lastCB == nil { + t.Error("expected ExecuteAsync to have received a callback") } if !result.Async { t.Error("expected async result") } - at.cb(context.Background(), SilentResult("done")) + at.lastCB(context.Background(), SilentResult("done")) if !called { t.Error("expected callback to be invoked") } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index 8b166b41f..be40ffda2 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -8,25 +8,18 @@ import ( type SpawnTool struct { manager *SubagentManager - originChannel string - originChatID string allowlistCheck func(targetAgentID string) bool - callback AsyncCallback // For async completion notification } +// Compile-time check: SpawnTool implements AsyncExecutor. +var _ AsyncExecutor = (*SpawnTool)(nil) + func NewSpawnTool(manager *SubagentManager) *SpawnTool { return &SpawnTool{ - manager: manager, - originChannel: "cli", - originChatID: "direct", + manager: manager, } } -// SetCallback implements AsyncTool interface for async completion notification -func (t *SpawnTool) SetCallback(cb AsyncCallback) { - t.callback = cb -} - func (t *SpawnTool) Name() string { return "spawn" } @@ -56,16 +49,21 @@ func (t *SpawnTool) Parameters() map[string]any { } } -func (t *SpawnTool) SetContext(channel, chatID string) { - t.originChannel = channel - t.originChatID = chatID -} - func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { t.allowlistCheck = check } func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + return t.execute(ctx, args, nil) +} + +// ExecuteAsync implements AsyncExecutor. The callback is passed through to the +// subagent manager as a call parameter — never stored on the SpawnTool instance. +func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { + return t.execute(ctx, args, cb) +} + +func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { task, ok := args["task"].(string) if !ok || strings.TrimSpace(task) == "" { return ErrorResult("task is required and must be a non-empty string") @@ -85,8 +83,20 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul return ErrorResult("Subagent manager not configured") } + // Read channel/chatID from context (injected by registry). + // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) + // to preserve the same defaults as the original NewSpawnTool constructor. + channel := ToolChannel(ctx) + if channel == "" { + channel = "cli" + } + chatID := ToolChatID(ctx) + if chatID == "" { + chatID = "direct" + } + // Pass callback to manager for async completion notification - result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback) + result, err := t.manager.Spawn(ctx, task, label, agentID, channel, chatID, cb) if err != nil { return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 69f1a49a2..429340047 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -252,16 +252,12 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask { // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion // and returns the result directly in the ToolResult. type SubagentTool struct { - manager *SubagentManager - originChannel string - originChatID string + manager *SubagentManager } func NewSubagentTool(manager *SubagentManager) *SubagentTool { return &SubagentTool{ - manager: manager, - originChannel: "cli", - originChatID: "direct", + manager: manager, } } @@ -290,11 +286,6 @@ func (t *SubagentTool) Parameters() map[string]any { } } -func (t *SubagentTool) SetContext(channel, chatID string) { - t.originChannel = channel - t.originChatID = chatID -} - func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) if !ok { @@ -341,13 +332,24 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } } + // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) + // to preserve the same defaults as the original NewSubagentTool constructor. + channel := ToolChannel(ctx) + if channel == "" { + channel = "cli" + } + chatID := ToolChatID(ctx) + if chatID == "" { + chatID = "direct" + } + loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, Model: sm.defaultModel, Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, - }, messages, t.originChannel, t.originChatID) + }, messages, channel, chatID) if err != nil { return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 59bfdffae..a1450410a 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -50,9 +50,8 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager.SetLLMOptions(2048, 0.6) tool := NewSubagentTool(manager) - tool.SetContext("cli", "direct") - ctx := context.Background() + ctx := WithToolContext(context.Background(), "cli", "direct") args := map[string]any{"task": "Do something"} result := tool.Execute(ctx, args) @@ -147,28 +146,14 @@ func TestSubagentTool_Parameters(t *testing.T) { } } -// TestSubagentTool_SetContext verifies context setting -func TestSubagentTool_SetContext(t *testing.T) { - provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) - tool := NewSubagentTool(manager) - - tool.SetContext("test-channel", "test-chat") - - // Verify context is set (we can't directly access private fields, - // but we can verify it doesn't crash) - // The actual context usage is tested in Execute tests -} - // TestSubagentTool_Execute_Success tests successful execution func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) tool := NewSubagentTool(manager) - tool.SetContext("telegram", "chat-123") - ctx := context.Background() + ctx := WithToolContext(context.Background(), "telegram", "chat-123") args := map[string]any{ "task": "Write a haiku about coding", "label": "haiku-task", @@ -297,12 +282,9 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) tool := NewSubagentTool(manager) - // Set context channel := "test-channel" chatID := "test-chat" - tool.SetContext(channel, chatID) - - ctx := context.Background() + ctx := WithToolContext(context.Background(), channel, chatID) args := map[string]any{ "task": "Test context passing", } From 41bb78f5939686235ac2cb4bd4bb50aaa44f11c6 Mon Sep 17 00:00:00 2001 From: Mauro Date: Thu, 5 Mar 2026 04:13:11 +0100 Subject: [PATCH 43/73] feat(ci) govulncheck (#1086) * feat(ci) govulncheck * feat(ci) disable persist-credentials --- .github/workflows/pr.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index be1c10c52..1e9a7919a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,6 +24,25 @@ jobs: with: version: v2.10.1 + vuln_check: + name: Security Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run Govulncheck + uses: golang/govulncheck-action@v1 + with: + go-package: ./... + test: name: Tests runs-on: ubuntu-latest From 5c599d2dacd2cbc471dc5d5af93fbc2df86041ad Mon Sep 17 00:00:00 2001 From: mosir Date: Thu, 5 Mar 2026 12:45:53 +0800 Subject: [PATCH 44/73] fix(exec): block kill command pattern in safety guard --- pkg/tools/shell.go | 1 + pkg/tools/shell_test.go | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a0c83eb1e..1c8cff99c 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -59,6 +59,7 @@ var ( regexp.MustCompile(`\bchown\b`), regexp.MustCompile(`\bpkill\b`), regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\b`), regexp.MustCompile(`\bkill\s+-[9]\b`), regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index a6abca8ea..ff9ea4a15 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -151,6 +151,26 @@ func TestShellTool_DangerousCommand(t *testing.T) { } } +func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + + ctx := context.Background() + args := map[string]any{ + "command": "kill 12345", + } + + result := tool.Execute(ctx, args) + if !result.IsError { + t.Errorf("Expected kill command to be blocked") + } + if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { + t.Errorf("Expected blocked message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} + // TestShellTool_MissingCommand verifies error handling for missing command func TestShellTool_MissingCommand(t *testing.T) { tool, err := NewExecTool("", false) From 10ad9e83f96c81bc7059abb85a3ac68384cfdead Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:15:16 +0800 Subject: [PATCH 45/73] docs: update license (#1131) --- LICENSE | 4 ---- 1 file changed, 4 deletions(-) diff --git a/LICENSE b/LICENSE index 410acae26..b38d9340d 100644 --- a/LICENSE +++ b/LICENSE @@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS. From 6f5930624b1cfaea113279f637a45569eafa812b Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:53:26 +0800 Subject: [PATCH 46/73] Feat/add tool enable or disable configuration (#1071) * Add tools enable or diable config --- cmd/picoclaw/internal/gateway/helpers.go | 28 +++-- config/config.example.json | 87 ++++++++++++-- pkg/agent/instance.go | 33 +++-- pkg/agent/loop.go | 147 ++++++++++++++--------- pkg/agent/loop_test.go | 15 +-- pkg/config/config.go | 105 +++++++++++++--- pkg/config/defaults.go | 59 ++++++++- pkg/mcp/manager_test.go | 16 ++- 8 files changed, 367 insertions(+), 123 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 5225340c7..174f5db62 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -230,19 +230,25 @@ func setupCronTool( // Create cron service cronService := cron.NewCronService(cronStorePath, nil) - // Create and register CronTool - cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) - if err != nil { - log.Fatalf("Critical error during CronTool initialization: %v", err) + // Create and register CronTool if enabled + var cronTool *tools.CronTool + if cfg.Tools.IsToolEnabled("cron") { + var err error + cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) + if err != nil { + log.Fatalf("Critical error during CronTool initialization: %v", err) + } + + agentLoop.RegisterTool(cronTool) } - agentLoop.RegisterTool(cronTool) - - // Set the onJob handler - cronService.SetOnJob(func(job *cron.CronJob) (string, error) { - result := cronTool.ExecuteJob(context.Background(), job) - return result, nil - }) + // Set onJob handler + if cronTool != nil { + cronService.SetOnJob(func(job *cron.CronJob) (string, error) { + result := cronTool.ExecuteJob(context.Background(), job) + return result, nil + }) + } return cronService } diff --git a/config/config.example.json b/config/config.example.json index c59a39885..ef1bf3eda 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -232,24 +232,41 @@ } }, "tools": { + "allow_read_paths": null, + "allow_write_paths": null, "web": { + "enabled": true, "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, + "tavily": { + "enabled": false, + "api_key": "", + "base_url": "", + "max_results": 0 + }, "duckduckgo": { "enabled": true, "max_results": 5 }, "perplexity": { "enabled": false, - "api_key": "pplx-xxx", + "api_key": "", "max_results": 5 }, - "proxy": "" + "glm_search": { + "enabled": false, + "api_key": "", + "base_url": "https://open.bigmodel.cn/api/paas/v4/web_search", + "search_engine": "search_std", + "max_results": 5 + }, + "fetch_limit_bytes": 10485760 }, "cron": { + "enabled": true, "exec_timeout_minutes": 5 }, "mcp": { @@ -318,19 +335,75 @@ } }, "exec": { - "enable_deny_patterns": false, - "custom_deny_patterns": [] + "enabled": true, + "enable_deny_patterns": true, + "custom_deny_patterns": null, + "custom_allow_patterns": null }, "skills": { + "enabled": true, "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", - "search_path": "/api/v1/search", - "skills_path": "/api/v1/skills", - "download_path": "/api/v1/download" + "auth_token": "", + "search_path": "", + "skills_path": "", + "download_path": "", + "timeout": 0, + "max_zip_size": 0, + "max_response_size": 0 } + }, + "max_concurrent_searches": 2, + "search_cache": { + "max_size": 50, + "ttl_seconds": 300 } + }, + "media_cleanup": { + "enabled": true, + "max_age_minutes": 30, + "interval_minutes": 5 + }, + "append_file": { + "enabled": true + }, + "edit_file": { + "enabled": true + }, + "find_skills": { + "enabled": true + }, + "i2c": { + "enabled": false + }, + "install_skill": { + "enabled": true + }, + "list_dir": { + "enabled": true + }, + "message": { + "enabled": true + }, + "read_file": { + "enabled": true + }, + "spawn": { + "enabled": true + }, + "spi": { + "enabled": false + }, + "subagent": { + "enabled": true + }, + "web_fetch": { + "enabled": true + }, + "write_file": { + "enabled": true } }, "heartbeat": { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 1e18b6f64..e14acf06d 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -60,17 +60,30 @@ func NewAgentInstance( allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() - toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) - toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) - toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) - execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) - if err != nil { - log.Fatalf("Critical error: unable to initialize exec tool: %v", err) - } - toolsRegistry.Register(execTool) - toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) - toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) + if cfg.Tools.IsToolEnabled("read_file") { + toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) + } + if cfg.Tools.IsToolEnabled("write_file") { + toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) + } + if cfg.Tools.IsToolEnabled("list_dir") { + toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) + } + if cfg.Tools.IsToolEnabled("exec") { + execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) + if err != nil { + log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + } + toolsRegistry.Register(execTool) + } + + if cfg.Tools.IsToolEnabled("edit_file") { + toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) + } + if cfg.Tools.IsToolEnabled("append_file") { + toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) + } sessionsDir := filepath.Join(workspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 263eeb4dd..1ab79f3ca 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -108,76 +108,102 @@ func registerSharedTools( } // Web tools - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - 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 { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) - } else if searchTool != nil { - agent.Tools.Register(searchTool) + if cfg.Tools.IsToolEnabled("web") { + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + 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 { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + } else if searchTool != nil { + agent.Tools.Register(searchTool) + } } - fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else { - agent.Tools.Register(fetchTool) + if cfg.Tools.IsToolEnabled("web_fetch") { + fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else { + agent.Tools.Register(fetchTool) + } } // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - agent.Tools.Register(tools.NewI2CTool()) - agent.Tools.Register(tools.NewSPITool()) + if cfg.Tools.IsToolEnabled("i2c") { + agent.Tools.Register(tools.NewI2CTool()) + } + if cfg.Tools.IsToolEnabled("spi") { + agent.Tools.Register(tools.NewSPITool()) + } // Message tool - messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, + if cfg.Tools.IsToolEnabled("message") { + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func(channel, chatID, content string) error { + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, + }) }) - }) - agent.Tools.Register(messageTool) + agent.Tools.Register(messageTool) + } // Skill discovery and installation tools - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) - searchCache := skills.NewSearchCache( - cfg.Tools.Skills.SearchCache.MaxSize, - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") + install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") + if find_skills_enable || install_skills_enable { + registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ + MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + }) + + if find_skills_enable { + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, + ) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) + } + + if install_skills_enable { + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + } + } // Spawn tool with allowlist checker - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) - spawnTool := tools.NewSpawnTool(subagentManager) - currentAgentID := agentID - spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { - return registry.CanSpawnSubagent(currentAgentID, targetAgentID) - }) - agent.Tools.Register(spawnTool) + if cfg.Tools.IsToolEnabled("spawn") { + if cfg.Tools.IsToolEnabled("subagent") { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + spawnTool := tools.NewSpawnTool(subagentManager) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(spawnTool) + } else { + logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) + } + } } } @@ -185,7 +211,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) // Initialize MCP servers for all agents - if al.cfg.Tools.MCP.Enabled { + if al.cfg.Tools.IsToolEnabled("mcp") { 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 @@ -227,6 +253,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { if !ok { continue } + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) agent.Tools.Register(mcpTool) totalRegistrations++ diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4ab6b4542..aa7d59b5a 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -227,16 +227,11 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { } defer os.RemoveAll(tmpDir) - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.Model = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 msgBus := bus.NewMessageBus() provider := &mockProvider{} diff --git a/pkg/config/config.go b/pkg/config/config.go index 3cfebf5e8..0ee3acfe0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -526,6 +526,10 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } +type ToolConfig struct { + Enabled bool `json:"enabled" env:"ENABLED"` +} + type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` @@ -561,11 +565,12 @@ type GLMSearchConfig struct { } type WebToolsConfig struct { - Brave BraveConfig `json:"brave"` - Tavily TavilyConfig `json:"tavily"` - DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` - Perplexity PerplexityConfig `json:"perplexity"` - GLMSearch GLMSearchConfig `json:"glm_search"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` + 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"` @@ -573,19 +578,28 @@ type WebToolsConfig struct { } type CronToolsConfig struct { - ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` + ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout } type ExecConfig struct { - EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` - CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` - CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` + EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` + CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` + CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` +} + +type SkillsToolsConfig struct { + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` + Registries SkillsRegistriesConfig ` json:"registries"` + MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` + SearchCache SearchCacheConfig ` json:"search_cache"` } type MediaCleanupConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"` - MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` - Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` + ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` + MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"` + Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` } type ToolsConfig struct { @@ -597,12 +611,19 @@ type ToolsConfig struct { Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` -} - -type SkillsToolsConfig struct { - Registries SkillsRegistriesConfig `json:"registries"` - MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"` - SearchCache SearchCacheConfig `json:"search_cache"` + AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` + EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` + FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` + I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` + InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` + Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` + ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` + Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } type SearchCacheConfig struct { @@ -648,8 +669,7 @@ type MCPServerConfig struct { // 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"` + ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } @@ -835,3 +855,48 @@ func (c *Config) ValidateModelList() error { } return nil } + +func (t *ToolsConfig) IsToolEnabled(name string) bool { + switch name { + case "web": + return t.Web.Enabled + case "cron": + return t.Cron.Enabled + case "exec": + return t.Exec.Enabled + case "skills": + return t.Skills.Enabled + case "media_cleanup": + return t.MediaCleanup.Enabled + case "append_file": + return t.AppendFile.Enabled + case "edit_file": + return t.EditFile.Enabled + case "find_skills": + return t.FindSkills.Enabled + case "i2c": + return t.I2C.Enabled + case "install_skill": + return t.InstallSkill.Enabled + case "list_dir": + return t.ListDir.Enabled + case "message": + return t.Message.Enabled + case "read_file": + return t.ReadFile.Enabled + case "spawn": + return t.Spawn.Enabled + case "spi": + return t.SPI.Enabled + case "subagent": + return t.Subagent.Enabled + case "web_fetch": + return t.WebFetch.Enabled + case "write_file": + return t.WriteFile.Enabled + case "mcp": + return t.MCP.Enabled + default: + return true + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 84fc60435..488590e28 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -336,11 +336,16 @@ func DefaultConfig() *Config { }, Tools: ToolsConfig{ MediaCleanup: MediaCleanupConfig{ - Enabled: true, + ToolConfig: ToolConfig{ + Enabled: true, + }, MaxAge: 30, Interval: 5, }, Web: WebToolsConfig{ + ToolConfig: ToolConfig{ + Enabled: true, + }, Proxy: "", FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default Brave: BraveConfig{ @@ -366,12 +371,21 @@ func DefaultConfig() *Config { }, }, Cron: CronToolsConfig{ + ToolConfig: ToolConfig{ + Enabled: true, + }, ExecTimeoutMinutes: 5, }, Exec: ExecConfig{ + ToolConfig: ToolConfig{ + Enabled: true, + }, EnableDenyPatterns: true, }, Skills: SkillsToolsConfig{ + ToolConfig: ToolConfig{ + Enabled: true, + }, Registries: SkillsRegistriesConfig{ ClawHub: ClawHubRegistryConfig{ Enabled: true, @@ -385,9 +399,50 @@ func DefaultConfig() *Config { }, }, MCP: MCPConfig{ - Enabled: false, + ToolConfig: ToolConfig{ + Enabled: false, + }, Servers: map[string]MCPServerConfig{}, }, + AppendFile: ToolConfig{ + Enabled: true, + }, + EditFile: ToolConfig{ + Enabled: true, + }, + FindSkills: ToolConfig{ + Enabled: true, + }, + I2C: ToolConfig{ + Enabled: false, // Hardware tool - Linux only + }, + InstallSkill: ToolConfig{ + Enabled: true, + }, + ListDir: ToolConfig{ + Enabled: true, + }, + Message: ToolConfig{ + Enabled: true, + }, + ReadFile: ToolConfig{ + Enabled: true, + }, + Spawn: ToolConfig{ + Enabled: true, + }, + SPI: ToolConfig{ + Enabled: false, // Hardware tool - Linux only + }, + Subagent: ToolConfig{ + Enabled: true, + }, + WebFetch: ToolConfig{ + Enabled: true, + }, + WriteFile: ToolConfig{ + Enabled: true, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index 8ce81d09e..f353942ab 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -194,7 +194,9 @@ func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) { mgr := NewManager() mcpCfg := config.MCPConfig{ - Enabled: true, + ToolConfig: config.ToolConfig{ + Enabled: true, + }, Servers: map[string]config.MCPServerConfig{ "test-server": { Enabled: true, @@ -228,12 +230,20 @@ func TestNewManager_InitialState(t *testing.T) { func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) { mgr := NewManager() - err := mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: false}, "/tmp") + err := mgr.LoadFromMCPConfig( + context.Background(), + config.MCPConfig{ToolConfig: config.ToolConfig{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") + err = mgr.LoadFromMCPConfig( + context.Background(), + config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: true}}, + "/tmp", + ) if err != nil { t.Fatalf("expected nil error when no servers configured, got: %v", err) } From 7a2fdc24dc202012db849722ba92df8238257b3e Mon Sep 17 00:00:00 2001 From: qs3c <2749950753@qq.com> Date: Thu, 5 Mar 2026 15:00:06 +0800 Subject: [PATCH 47/73] fix(skills): retry ClawHub requests on 429 --- docs/tools_configuration.md | 2 + pkg/skills/clawhub_registry.go | 162 ++++++++++++++++++++++++---- pkg/skills/clawhub_registry_test.go | 81 ++++++++++++++ 3 files changed, 225 insertions(+), 20 deletions(-) diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 6204fb0c8..e64a3a107 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -180,6 +180,7 @@ The skills tool configures skill discovery and installation via registries like | ---------------------------------- | ------ | -------------------- | ----------------------- | | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | +| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | | `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 | @@ -194,6 +195,7 @@ The skills tool configures skill discovery and installation via registries like "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", + "auth_token": "", "search_path": "/api/v1/search", "skills_path": "/api/v1/skills", "download_path": "/api/v1/download" diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index f78197bbe..b520f3260 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "os" + "strconv" + "strings" "time" "github.com/sipeed/picoclaw/pkg/utils" @@ -17,6 +19,7 @@ const ( defaultClawHubTimeout = 30 * time.Second defaultMaxZipSize = 50 * 1024 * 1024 // 50 MB defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB + defaultMaxRetries = 3 ) // ClawHubRegistry implements SkillRegistry for the ClawHub platform. @@ -259,15 +262,7 @@ func (c *ClawHubRegistry) DownloadAndInstall( } u.RawQuery = q.Encode() - req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - if c.authToken != "" { - req.Header.Set("Authorization", "Bearer "+c.authToken) - } - - tmpPath, err := utils.DownloadToFile(ctx, c.client, req, int64(c.maxZipSize)) + tmpPath, err := c.downloadToTempFileWithRetry(ctx, u.String()) if err != nil { return nil, fmt.Errorf("download failed: %w", err) } @@ -284,17 +279,7 @@ func (c *ClawHubRegistry) DownloadAndInstall( // --- HTTP helper --- func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", "application/json") - if c.authToken != "" { - req.Header.Set("Authorization", "Bearer "+c.authToken) - } - - resp, err := c.client.Do(req) + resp, err := c.doGetWithRetry(ctx, urlStr, "application/json") if err != nil { return nil, err } @@ -312,3 +297,140 @@ func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, err return body, nil } + +func (c *ClawHubRegistry) doGetWithRetry(ctx context.Context, urlStr, accept string) (*http.Response, error) { + var lastErr error + for attempt := 0; attempt < defaultMaxRetries; attempt++ { + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", accept) + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + } else { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + + if !isRetryableStatus(resp.StatusCode) || attempt == defaultMaxRetries-1 { + return resp, nil + } + + delay := retryDelay(resp.Header.Get("Retry-After"), attempt) + resp.Body.Close() + if err := sleepWithContext(ctx, delay); err != nil { + return nil, err + } + continue + } + + if attempt == defaultMaxRetries-1 { + return nil, lastErr + } + if err := sleepWithContext(ctx, retryDelay("", attempt)); err != nil { + return nil, err + } + } + return nil, lastErr +} + +func (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlStr string) (string, error) { + resp, err := c.doGetWithRetry(ctx, urlStr, "application/zip") + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + errBody := make([]byte, 512) + n, _ := io.ReadFull(resp.Body, errBody) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n])) + } + + tmpFile, err := os.CreateTemp("", "picoclaw-dl-*") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + cleanup := func() { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + } + + src := io.LimitReader(resp.Body, int64(c.maxZipSize)+1) + written, err := io.Copy(tmpFile, src) + if err != nil { + cleanup() + return "", fmt.Errorf("download write failed: %w", err) + } + + if written > int64(c.maxZipSize) { + cleanup() + return "", fmt.Errorf("download too large: %d bytes (max %d)", written, c.maxZipSize) + } + + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("failed to close temp file: %w", err) + } + + return tmpPath, nil +} + +func isRetryableStatus(statusCode int) bool { + return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError +} + +func retryDelay(retryAfter string, attempt int) time.Duration { + if d, ok := parseRetryAfter(retryAfter); ok { + return d + } + return time.Duration(attempt+1) * time.Second +} + +func parseRetryAfter(headerValue string) (time.Duration, bool) { + headerValue = strings.TrimSpace(headerValue) + if headerValue == "" { + return 0, false + } + + if sec, err := strconv.Atoi(headerValue); err == nil { + if sec < 0 { + sec = 0 + } + return time.Duration(sec) * time.Second, true + } + + if resetAt, err := http.ParseTime(headerValue); err == nil { + d := time.Until(resetAt) + if d < 0 { + d = 0 + } + return d, true + } + + return 0, false +} + +func sleepWithContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go index 65ee638da..055da22dc 100644 --- a/pkg/skills/clawhub_registry_test.go +++ b/pkg/skills/clawhub_registry_test.go @@ -54,6 +54,39 @@ func TestClawHubRegistrySearch(t *testing.T) { assert.Equal(t, "clawhub", results[0].RegistryName) } +func TestClawHubRegistrySearchRetries429(t *testing.T) { + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("rate limited")) + return + } + + slug := "github" + name := "GitHub Integration" + summary := "Interact with GitHub repos" + version := "1.0.0" + + json.NewEncoder(w).Encode(clawhubSearchResponse{ + Results: []clawhubSearchResult{ + {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version}, + }, + }) + })) + defer srv.Close() + + reg := newTestRegistry(srv.URL, "") + results, err := reg.Search(context.Background(), "github", 5) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, 2, attempts) + assert.Equal(t, "github", results[0].Slug) +} + func TestClawHubRegistryGetSkillMeta(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/skills/github", r.URL.Path) @@ -137,6 +170,54 @@ func TestClawHubRegistryDownloadAndInstall(t *testing.T) { assert.Contains(t, string(readmeContent), "# Test Skill") } +func TestClawHubRegistryDownloadAndInstallRetries429(t *testing.T) { + zipBuf := createTestZip(t, map[string]string{ + "SKILL.md": "---\nname: retry-skill\ndescription: A test\n---\nHello skill", + }) + + downloadAttempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/retry-skill": + json.NewEncoder(w).Encode(clawhubSkillResponse{ + Slug: "retry-skill", + DisplayName: "Retry Skill", + Summary: "A retry test skill", + LatestVersion: &clawhubVersionInfo{Version: "1.0.0"}, + }) + case "/api/v1/download": + downloadAttempts++ + if downloadAttempts == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("rate limited")) + return + } + assert.Equal(t, "retry-skill", r.URL.Query().Get("slug")) + w.Header().Set("Content-Type", "application/zip") + w.Write(zipBuf) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + tmpDir := t.TempDir() + targetDir := filepath.Join(tmpDir, "retry-skill") + + reg := newTestRegistry(srv.URL, "") + result, err := reg.DownloadAndInstall(context.Background(), "retry-skill", "", targetDir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "1.0.0", result.Version) + assert.Equal(t, 2, downloadAttempts) + + skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(skillContent), "Hello skill") +} + func TestClawHubRegistryAuthToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") From ab120af64906537409c297c9c56d1bf80492926e Mon Sep 17 00:00:00 2001 From: cornjosh Date: Thu, 5 Mar 2026 17:10:04 +0800 Subject: [PATCH 48/73] fix(skills): use --registry flag value as registry name The --registry flag value was previously ignored and only used as a switch. Now the flag value is properly used as the registry name. Fixes #1104 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- cmd/picoclaw/internal/skills/install.go | 6 +- cmd/picoclaw/internal/skills/install_test.go | 69 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go index a30f68632..78bc421db 100644 --- a/cmd/picoclaw/internal/skills/install.go +++ b/cmd/picoclaw/internal/skills/install.go @@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github `, Args: func(cmd *cobra.Command, args []string) error { if registry != "" { - if len(args) != 2 { - return fmt.Errorf("when --registry is set, exactly 2 arguments are required: ") + if len(args) != 1 { + return fmt.Errorf("when --registry is set, exactly 1 argument is required: ") } return nil } @@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github return err } - return skillsInstallFromRegistry(cfg, args[0], args[1]) + return skillsInstallFromRegistry(cfg, registry, args[0]) } return skillsInstallCmd(installer, args[0]) diff --git a/cmd/picoclaw/internal/skills/install_test.go b/cmd/picoclaw/internal/skills/install_test.go index 97787a986..6b362822d 100644 --- a/cmd/picoclaw/internal/skills/install_test.go +++ b/cmd/picoclaw/internal/skills/install_test.go @@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) { assert.Len(t, cmd.Aliases, 0) } + +func TestInstallCommandArgs(t *testing.T) { + tests := []struct { + name string + args []string + registry string + expectError bool + errorMsg string + }{ + { + name: "no registry, one arg", + args: []string{"sipeed/picoclaw-skills/weather"}, + registry: "", + expectError: false, + }, + { + name: "no registry, no args", + args: []string{}, + registry: "", + expectError: true, + errorMsg: "exactly 1 argument is required: ", + }, + { + name: "no registry, too many args", + args: []string{"arg1", "arg2"}, + registry: "", + expectError: true, + errorMsg: "exactly 1 argument is required: ", + }, + { + name: "with registry, one arg", + args: []string{"weather-skill"}, + registry: "clawhub", + expectError: false, + }, + { + name: "with registry, no args", + args: []string{}, + registry: "clawhub", + expectError: true, + errorMsg: "when --registry is set, exactly 1 argument is required: ", + }, + { + name: "with registry, too many args", + args: []string{"arg1", "arg2"}, + registry: "clawhub", + expectError: true, + errorMsg: "when --registry is set, exactly 1 argument is required: ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newInstallCommand(nil) + + if tt.registry != "" { + require.NoError(t, cmd.Flags().Set("registry", tt.registry)) + } + + err := cmd.Args(cmd, tt.args) + if tt.expectError { + require.Error(t, err) + assert.Equal(t, tt.errorMsg, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} From 536e9ac9de6aadf97ea23fbe21f7c6126589a625 Mon Sep 17 00:00:00 2001 From: qs3c <2749950753@qq.com> Date: Thu, 5 Mar 2026 19:10:36 +0800 Subject: [PATCH 49/73] refactor(skills): reuse shared HTTP retry helper --- pkg/skills/clawhub_registry.go | 116 ++++++--------------------------- 1 file changed, 21 insertions(+), 95 deletions(-) diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index b520f3260..bd4bed8fb 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -8,8 +8,6 @@ import ( "net/http" "net/url" "os" - "strconv" - "strings" "time" "github.com/sipeed/picoclaw/pkg/utils" @@ -19,7 +17,6 @@ const ( defaultClawHubTimeout = 30 * time.Second defaultMaxZipSize = 50 * 1024 * 1024 // 50 MB defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB - defaultMaxRetries = 3 ) // ClawHubRegistry implements SkillRegistry for the ClawHub platform. @@ -279,7 +276,12 @@ func (c *ClawHubRegistry) DownloadAndInstall( // --- HTTP helper --- func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) { - resp, err := c.doGetWithRetry(ctx, urlStr, "application/json") + req, err := c.newGetRequest(ctx, urlStr, "application/json") + if err != nil { + return nil, err + } + + resp, err := utils.DoRequestWithRetry(c.client, req) if err != nil { return nil, err } @@ -298,50 +300,25 @@ func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, err return body, nil } -func (c *ClawHubRegistry) doGetWithRetry(ctx context.Context, urlStr, accept string) (*http.Response, error) { - var lastErr error - for attempt := 0; attempt < defaultMaxRetries; attempt++ { - req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", accept) - if c.authToken != "" { - req.Header.Set("Authorization", "Bearer "+c.authToken) - } - - resp, err := c.client.Do(req) - if err != nil { - lastErr = err - } else { - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return resp, nil - } - - if !isRetryableStatus(resp.StatusCode) || attempt == defaultMaxRetries-1 { - return resp, nil - } - - delay := retryDelay(resp.Header.Get("Retry-After"), attempt) - resp.Body.Close() - if err := sleepWithContext(ctx, delay); err != nil { - return nil, err - } - continue - } - - if attempt == defaultMaxRetries-1 { - return nil, lastErr - } - if err := sleepWithContext(ctx, retryDelay("", attempt)); err != nil { - return nil, err - } +func (c *ClawHubRegistry) newGetRequest(ctx context.Context, urlStr, accept string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return nil, err } - return nil, lastErr + req.Header.Set("Accept", accept) + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + return req, nil } func (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlStr string) (string, error) { - resp, err := c.doGetWithRetry(ctx, urlStr, "application/zip") + req, err := c.newGetRequest(ctx, urlStr, "application/zip") + if err != nil { + return "", err + } + + resp, err := utils.DoRequestWithRetry(c.client, req) if err != nil { return "", err } @@ -383,54 +360,3 @@ func (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlSt return tmpPath, nil } - -func isRetryableStatus(statusCode int) bool { - return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError -} - -func retryDelay(retryAfter string, attempt int) time.Duration { - if d, ok := parseRetryAfter(retryAfter); ok { - return d - } - return time.Duration(attempt+1) * time.Second -} - -func parseRetryAfter(headerValue string) (time.Duration, bool) { - headerValue = strings.TrimSpace(headerValue) - if headerValue == "" { - return 0, false - } - - if sec, err := strconv.Atoi(headerValue); err == nil { - if sec < 0 { - sec = 0 - } - return time.Duration(sec) * time.Second, true - } - - if resetAt, err := http.ParseTime(headerValue); err == nil { - d := time.Until(resetAt) - if d < 0 { - d = 0 - } - return d, true - } - - return 0, false -} - -func sleepWithContext(ctx context.Context, delay time.Duration) error { - if delay <= 0 { - return nil - } - - timer := time.NewTimer(delay) - defer timer.Stop() - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} From 943385105fc432ec7e09c75aedb16d77050bc788 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 5 Mar 2026 20:56:38 +0900 Subject: [PATCH 50/73] fix: handle ignored io.ReadAll errors across codebase io.ReadAll errors were silently discarded with `body, _ := io.ReadAll(...)`, which could cause empty or partial data to be used for JSON unmarshaling or error messages. This adds proper error checks for all instances. --- .../internal/ui/model.go | 6 ++++- .../internal/server/auth_handlers.go | 5 +++- cmd/picoclaw/internal/auth/helpers.go | 5 +++- pkg/auth/oauth.go | 25 +++++++++++++++---- pkg/channels/line/line.go | 5 +++- pkg/channels/wecom/aibot.go | 5 +++- pkg/channels/wecom/app.go | 10 ++++++-- pkg/channels/wecom/bot.go | 5 +++- pkg/providers/antigravity_provider.go | 10 ++++++-- 9 files changed, 61 insertions(+), 15 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go index ba91f5b09..304b4efa7 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/model.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/model.go @@ -335,7 +335,11 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) { s.showMessage("Test OK", resp.Status) return } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + body, err := io.ReadAll(io.LimitReader(resp.Body, 2048)) + if err != nil { + s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err)) + return + } s.showMessage( "Test failed", fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), diff --git a/cmd/picoclaw-launcher/internal/server/auth_handlers.go b/cmd/picoclaw-launcher/internal/server/auth_handlers.go index 1e9b8be0a..3b48f9739 100644 --- a/cmd/picoclaw-launcher/internal/server/auth_handlers.go +++ b/cmd/picoclaw-launcher/internal/server/auth_handlers.go @@ -297,7 +297,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading userinfo response: %w", err) + } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("userinfo request failed: %s", string(body)) } diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 633ce8740..4dfbc92e7 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -177,7 +177,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading userinfo response: %w", err) + } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("userinfo request failed: %s", string(body)) } diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 91c9e25c5..4667e3d81 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -212,7 +212,10 @@ func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading device code response: %w", err) + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("device code request failed: %s", string(body)) } @@ -300,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading device code response: %w", err) + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("device code request failed: %s", string(body)) } @@ -360,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au return nil, fmt.Errorf("pending") } - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading device token response: %w", err) + } var tokenResp struct { AuthorizationCode string `json:"authorization_code"` @@ -401,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading token refresh response: %w", err) + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token refresh failed: %s", string(body)) } @@ -494,7 +506,10 @@ func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading token exchange response: %w", err) + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token exchange failed: %s", string(body)) } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 398f12e6b..d0badc1f3 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -654,7 +654,10 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading LINE API error response: %w", err) + } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 6c5aca40b..93fe8c36d 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -793,7 +793,10 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro return nil } - respBody, _ := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err) + } switch { case resp.StatusCode == http.StatusTooManyRequests: return fmt.Errorf("response_url rate limited (%d): %s: %w", diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 717815b9f..c1aa9640f 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -321,7 +321,10 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading wecom upload error response: %w", err) + } return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody))) } @@ -371,7 +374,10 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading wecom_app error response: %w", err) + } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody))) } diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 9126a847d..3740bcd41 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -453,7 +453,10 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading webhook error response: %w", err) + } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body))) } diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go index d4ee528b7..8a1890212 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/antigravity_provider.go @@ -640,7 +640,10 @@ func FetchAntigravityProjectID(accessToken string) (string, error) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading loadCodeAssist response: %w", err) + } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("loadCodeAssist failed: %s", string(body)) } @@ -681,7 +684,10 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading fetchAvailableModels response: %w", err) + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf( "fetchAvailableModels failed (HTTP %d): %s", From 8d2f2d67b2d57ad30f35df78b3ad612e69f42a08 Mon Sep 17 00:00:00 2001 From: mattn Date: Thu, 5 Mar 2026 21:20:20 +0900 Subject: [PATCH 51/73] Update pkg/channels/line/line.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/line/line.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index d0badc1f3..b36350a06 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -656,7 +656,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) if resp.StatusCode != http.StatusOK { respBody, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("reading LINE API error response: %w", err) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err)) } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } From ca4e44bd0feef193ae12dbb4bf53b731639ad52a Mon Sep 17 00:00:00 2001 From: mattn Date: Thu, 5 Mar 2026 21:20:31 +0900 Subject: [PATCH 52/73] Update pkg/channels/wecom/bot.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/bot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 3740bcd41..0e281ff2f 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -455,7 +455,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("reading webhook error response: %w", err) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading webhook error response: %w", err)) } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body))) } From ee2ebc8bf35141abc609457c92964baf73c08335 Mon Sep 17 00:00:00 2001 From: mattn Date: Thu, 5 Mar 2026 21:20:40 +0900 Subject: [PATCH 53/73] Update pkg/channels/wecom/app.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index c1aa9640f..1759abaa3 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -323,7 +323,7 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp if resp.StatusCode != http.StatusOK { respBody, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("reading wecom upload error response: %w", err) + return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading wecom upload error response: %w", err)) } return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody))) } From 42a32fbf3bb956340e59985f2d56f36fde4db1d8 Mon Sep 17 00:00:00 2001 From: mattn Date: Thu, 5 Mar 2026 21:20:51 +0900 Subject: [PATCH 54/73] Update pkg/channels/wecom/app.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/channels/wecom/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 1759abaa3..bca47ea8e 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -376,7 +376,7 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri if resp.StatusCode != http.StatusOK { respBody, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("reading wecom_app error response: %w", err) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading wecom_app error response: %w", err)) } return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody))) } From f046ba59e80e6f901067513913490af83affa117 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Thu, 5 Mar 2026 15:40:06 +0300 Subject: [PATCH 55/73] fix(agent): respect global skills toggle for skill tools --- pkg/agent/loop.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 1ab79f3ca..fa001a9c9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -168,9 +168,10 @@ func registerSharedTools( } // Skill discovery and installation tools + skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") - if find_skills_enable || install_skills_enable { + if skills_enabled && (find_skills_enable || install_skills_enable) { registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), From b8782729623e55ab09a985259fd46c5c7ee0d95a Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 5 Mar 2026 21:54:13 +0900 Subject: [PATCH 56/73] fix: resolve govet shadow and golines lint errors in wecom channels --- pkg/channels/wecom/app.go | 24 ++++++++++++++++++++++-- pkg/channels/wecom/bot.go | 11 ++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index bca47ea8e..b86f7ae2b 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -321,11 +321,22 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp defer resp.Body.Close() if resp.StatusCode != http.StatusOK { +<<<<<<< HEAD respBody, err := io.ReadAll(resp.Body) if err != nil { return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading wecom upload error response: %w", err)) +======= + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", fmt.Errorf( + "reading wecom upload error response: %w", readErr, + ) +>>>>>>> 908fa8d (fix: resolve govet shadow and golines lint errors in wecom channels) } - return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody))) + return "", channels.ClassifySendError( + resp.StatusCode, + fmt.Errorf("wecom upload error: %s", string(respBody)), + ) } var result struct { @@ -374,11 +385,20 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri defer resp.Body.Close() if resp.StatusCode != http.StatusOK { +<<<<<<< HEAD respBody, err := io.ReadAll(resp.Body) if err != nil { return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading wecom_app error response: %w", err)) +======= + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("reading wecom_app error response: %w", readErr) +>>>>>>> 908fa8d (fix: resolve govet shadow and golines lint errors in wecom channels) } - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody))) + return channels.ClassifySendError( + resp.StatusCode, + fmt.Errorf("wecom_app API error: %s", string(respBody)), + ) } respBody, err := io.ReadAll(resp.Body) diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 0e281ff2f..8d64a91c6 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -453,11 +453,20 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content defer resp.Body.Close() if resp.StatusCode != http.StatusOK { +<<<<<<< HEAD body, err := io.ReadAll(resp.Body) if err != nil { return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading webhook error response: %w", err)) +======= + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("reading webhook error response: %w", readErr) +>>>>>>> 908fa8d (fix: resolve govet shadow and golines lint errors in wecom channels) } - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body))) + return channels.ClassifySendError( + resp.StatusCode, + fmt.Errorf("webhook API error: %s", string(body)), + ) } body, err := io.ReadAll(resp.Body) From 03d6ad420f573b4eff138c561ef79d564f3eeef1 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 5 Mar 2026 22:01:32 +0900 Subject: [PATCH 57/73] fix: resolve merge conflicts in wecom error handling Combine both shadow variable fix (readErr) and proper error classification (ClassifySendError) in wecom app and bot channels. --- pkg/channels/wecom/app.go | 22 +++++++--------------- pkg/channels/wecom/bot.go | 11 ++++------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index b86f7ae2b..2098fcd4e 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -321,17 +321,12 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp defer resp.Body.Close() if resp.StatusCode != http.StatusOK { -<<<<<<< HEAD - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading wecom upload error response: %w", err)) -======= respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { - return "", fmt.Errorf( - "reading wecom upload error response: %w", readErr, + return "", channels.ClassifySendError( + resp.StatusCode, + fmt.Errorf("reading wecom upload error response: %w", readErr), ) ->>>>>>> 908fa8d (fix: resolve govet shadow and golines lint errors in wecom channels) } return "", channels.ClassifySendError( resp.StatusCode, @@ -385,15 +380,12 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri defer resp.Body.Close() if resp.StatusCode != http.StatusOK { -<<<<<<< HEAD - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading wecom_app error response: %w", err)) -======= respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { - return fmt.Errorf("reading wecom_app error response: %w", readErr) ->>>>>>> 908fa8d (fix: resolve govet shadow and golines lint errors in wecom channels) + return channels.ClassifySendError( + resp.StatusCode, + fmt.Errorf("reading wecom_app error response: %w", readErr), + ) } return channels.ClassifySendError( resp.StatusCode, diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 8d64a91c6..96d5a961f 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -453,15 +453,12 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content defer resp.Body.Close() if resp.StatusCode != http.StatusOK { -<<<<<<< HEAD - body, err := io.ReadAll(resp.Body) - if err != nil { - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading webhook error response: %w", err)) -======= body, readErr := io.ReadAll(resp.Body) if readErr != nil { - return fmt.Errorf("reading webhook error response: %w", readErr) ->>>>>>> 908fa8d (fix: resolve govet shadow and golines lint errors in wecom channels) + return channels.ClassifySendError( + resp.StatusCode, + fmt.Errorf("reading webhook error response: %w", readErr), + ) } return channels.ClassifySendError( resp.StatusCode, From 51e8479f996c48fdbce243bf1a13e2b08b19c804 Mon Sep 17 00:00:00 2001 From: Keith Patrick Date: Thu, 5 Mar 2026 22:08:37 +0000 Subject: [PATCH 58/73] feat: honor PICOCLAW_HOME env var for config, auth, and workspace paths --- cmd/picoclaw/internal/helpers.go | 13 +++++++++++-- cmd/picoclaw/internal/helpers_test.go | 21 +++++++++++++++++++++ pkg/agent/context.go | 3 +++ pkg/agent/instance.go | 5 +++-- pkg/auth/store.go | 3 +++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index 9655d3c08..f81d7013d 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -18,12 +18,21 @@ var ( goVersion string ) +// GetPicoclawHome returns the picoclaw home directory. +// Priority: $PICOCLAW_HOME > ~/.picoclaw +func GetPicoclawHome() string { + if home := os.Getenv("PICOCLAW_HOME"); home != "" { + return home + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw") +} + func GetConfigPath() string { if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { return configPath } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "config.json") + return filepath.Join(GetPicoclawHome(), "config.json") } func LoadConfig() (*config.Config, error) { diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go index 47e2f8c07..646be1ba1 100644 --- a/cmd/picoclaw/internal/helpers_test.go +++ b/cmd/picoclaw/internal/helpers_test.go @@ -19,6 +19,27 @@ func TestGetConfigPath(t *testing.T) { assert.Equal(t, want, got) } +func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { + t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") + t.Setenv("HOME", "/tmp/home") + + got := GetConfigPath() + want := filepath.Join("/custom/picoclaw", "config.json") + + assert.Equal(t, want, got) +} + +func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { + t.Setenv("PICOCLAW_CONFIG", "/custom/config.json") + t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") + t.Setenv("HOME", "/tmp/home") + + got := GetConfigPath() + want := "/custom/config.json" + + assert.Equal(t, want, got) +} + func TestFormatVersion_NoGitCommit(t *testing.T) { oldVersion, oldGit := version, gitCommit t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 3aa903b3f..d84aea627 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -42,6 +42,9 @@ type ContextBuilder struct { } func getGlobalConfigDir() string { + if home := os.Getenv("PICOCLAW_HOME"); home != "" { + return home + } home, err := os.UserHomeDir() if err != nil { return "" diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index ed25f537f..9a92fbbfd 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -187,12 +187,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { return expandHome(strings.TrimSpace(agentCfg.Workspace)) } + // Use the configured default workspace (respects PICOCLAW_HOME) if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" { return expandHome(defaults.Workspace) } - home, _ := os.UserHomeDir() + // For named agents without explicit workspace, use default workspace with agent ID suffix id := routing.NormalizeAgentID(agentCfg.ID) - return filepath.Join(home, ".picoclaw", "workspace-"+id) + return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id) } // resolveAgentModel resolves the primary model for an agent. diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 283dc6977..2e55d4877 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -39,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool { } func authFilePath() string { + if home := os.Getenv("PICOCLAW_HOME"); home != "" { + return filepath.Join(home, "auth.json") + } home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw", "auth.json") } From e0d2be35c257097cb9c1ade8ee0768f45b90fb5e Mon Sep 17 00:00:00 2001 From: wangyanfu2 Date: Thu, 5 Mar 2026 17:20:11 +0800 Subject: [PATCH 59/73] fix(tools): make exec tool timeout configurable via config Add TimeoutSeconds field to ExecConfig so the shell command execution timeout can be configured instead of being hardcoded to 60s. - Add TimeoutSeconds int field to ExecConfig in pkg/config/config.go with json/env tags (PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS) - Set default value of 60s in DefaultConfig() in pkg/config/defaults.go - Read TimeoutSeconds from config in NewExecToolWithConfig() in pkg/tools/shell.go; falls back to 60s when value is 0 or unset --- pkg/config/config.go | 1 + pkg/config/defaults.go | 1 + pkg/tools/shell.go | 7 ++++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a0ec323c..8a5bd883f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -594,6 +594,7 @@ type ExecConfig struct { EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` + TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } type SkillsToolsConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index e87d7aa0a..c4c04d41a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -386,6 +386,7 @@ func DefaultConfig() *Config { Enabled: true, }, EnableDenyPatterns: true, + TimeoutSeconds: 60, }, Skills: SkillsToolsConfig{ ToolConfig: ToolConfig{ diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a0c83eb1e..2ea58b259 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -131,9 +131,14 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf denyPatterns = append(denyPatterns, defaultDenyPatterns...) } + timeout := 60 * time.Second + if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { + timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second + } + return &ExecTool{ workingDir: workingDir, - timeout: 60 * time.Second, + timeout: timeout, denyPatterns: denyPatterns, allowPatterns: nil, customAllowPatterns: customAllowPatterns, From 65e1434e1b2690c72c83ade2969e740766e6c2a3 Mon Sep 17 00:00:00 2001 From: wangyanfu2 Date: Fri, 6 Mar 2026 11:19:25 +0800 Subject: [PATCH 60/73] style: fix gofmt formatting in ExecConfig struct Remove extra trailing whitespace between struct tag and inline comment on TimeoutSeconds field to comply with gofmt formatting rules. --- 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 8a5bd883f..5f389f511 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -594,7 +594,7 @@ type ExecConfig struct { EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` - TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) + TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } type SkillsToolsConfig struct { From 46201fb679021cca56580d44a66ae0e0bb262452 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:47:29 +0800 Subject: [PATCH 61/73] feat: upload release artifacts to Volcengine TOS (#1164) Add reusable workflow (upload-tos.yml) to upload release archives to Volcengine TOS bucket. Supports both workflow_call from release pipeline and manual workflow_dispatch trigger. Uploads to versioned ({tag}/) and latest/ directories. Co-authored-by: Claude Opus 4.6 --- .github/workflows/release.yml | 8 ++++++ .github/workflows/upload-tos.yml | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 .github/workflows/upload-tos.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 786c893ef..6566afe96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,3 +100,11 @@ jobs: gh release edit "${{ inputs.tag }}" \ --draft=${{ inputs.draft }} \ --prerelease=${{ inputs.prerelease }} + + upload-tos: + name: Upload to TOS + needs: release + uses: ./.github/workflows/upload-tos.yml + with: + tag: ${{ inputs.tag }} + secrets: inherit diff --git a/.github/workflows/upload-tos.yml b/.github/workflows/upload-tos.yml new file mode 100644 index 000000000..6d3916d53 --- /dev/null +++ b/.github/workflows/upload-tos.yml @@ -0,0 +1,49 @@ +name: Upload to Volcengine TOS + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to download and upload (e.g. v0.2.0)" + required: true + type: string + workflow_call: + inputs: + tag: + description: "Release tag to download and upload" + required: true + type: string + +jobs: + upload-tos: + name: Upload to Volcengine TOS + runs-on: ubuntu-latest + steps: + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p artifacts + gh release download "${{ inputs.tag }}" \ + --repo "${{ github.repository }}" \ + --dir artifacts \ + --pattern "*.tar.gz" \ + --pattern "*.zip" \ + --pattern "*.rpm" \ + --pattern "*.deb" + + - name: Upload to Volcengine TOS + env: + AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }} + AWS_DEFAULT_REGION: cn-beijing + run: | + aws configure set default.s3.addressing_style virtual + TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com" + # Upload to versioned directory + aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \ + --endpoint-url "$TOS_ENDPOINT" + # Upload to latest (overwrite) + aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \ + --endpoint-url "$TOS_ENDPOINT" \ + --delete From 04ddb6b472e991a25fc05b6d3fba100649025d33 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Fri, 6 Mar 2026 12:20:21 +0800 Subject: [PATCH 62/73] chore: remove accidentally committed local files --- .claude/settings.local.json | 42 -------- PicoClaw 26M2W3 社区开发者会议.md | 161 ------------------------------ PicoClaw贡献方向规划.md | 108 -------------------- 3 files changed, 311 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 PicoClaw 26M2W3 社区开发者会议.md delete mode 100644 PicoClaw贡献方向规划.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index aa8927667..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cd:*)", - "Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -v -count=1 2>&1)", - "Bash(cd /e/Project/picoclaw && golangci-lint run ./pkg/memory/... 2>&1)", - "Bash(cd /e/Project/picoclaw && golangci-lint run ./pkg/memory/... --fix 2>&1)", - "Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -count=1 2>&1)", - "Bash(cd /e/Project/picoclaw && go vet ./pkg/memory/... 2>&1)", - "Bash(cd /e/Project/picoclaw && go build ./... 2>&1)", - "Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -bench=. -benchmem -run=^$ 2>&1)", - "Bash(cd /e/Project/picoclaw && go test ./pkg/session/... -count=1 2>&1)", - "mcp__sequential-thinking__sequentialthinking", - "Bash(cd /e/Project/picoclaw && git push -u origin feat/jsonl-memory-store 2>&1)", - "Bash(head:*)", - "WebSearch", - "Bash(cd /e/Project/picoclaw && gh issue view 711 --comments 2>&1)", - "Bash(cd /e/Project/picoclaw && gh pr view 732 --comments 2>&1)", - "Bash(cd /e/Project/picoclaw && gh pr view 732 2>&1)", - "Bash(cd /e/Project/picoclaw && gh pr checks 732 2>&1)", - "Bash(echo no upstream remote:*)", - "Bash(cd /e/Project/picoclaw && git rebase upstream/main 2>&1)", - "Bash(cd /e/Project/picoclaw && go build ./pkg/memory/... 2>&1)", - "Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -count=1 -v 2>&1)", - "Bash(gh api:*)", - "Bash(git push:*)", - "Bash(go test:*)", - "Bash(find .:*)", - "Bash(golangci-lint run:*)", - "Bash(gh pr:*)", - "Bash(gh issue:*)", - "Bash(git fetch:*)", - "Bash(echo exit: $?:*)", - "WebFetch(domain:github.com)", - "Bash(git log:*)", - "Bash(grep:*)", - "Bash(ls:*)", - "Bash(go build:*)", - "Bash(go vet:*)" - ] - } -} diff --git a/PicoClaw 26M2W3 社区开发者会议.md b/PicoClaw 26M2W3 社区开发者会议.md deleted file mode 100644 index ab356424e..000000000 --- a/PicoClaw 26M2W3 社区开发者会议.md +++ /dev/null @@ -1,161 +0,0 @@ -# PicoClaw 26M2W3 社区开发者会议 - -> **PicoClaw的设计目标**:轻量高效,任意部署;简单易用,普惠大众; -> **致PicoClaw开发者**:让我们携手加速AI奇点的到来,共同创造并见证历史。 - ---- - -## 26M2W3 概况 - -### 成果 -* **Github 表现**:Star 17K+,Merge 100+ PR,Contributors 70+ -* **用户规模**:微信群 1600+,Discord 1300+ -* **开发者规模**:微信群 ~50,Discord ~40 -* **生态进展**:PicoClaw 进入 Homebrew -* **工程进展**:Provider 完成重构 -* **特别鸣谢**:daming, lxowalle 在假期的努力! - -### 暴露的问题 -* 第一次开展大规模社区协同开发,又是在假期期间,响应速度、社区协调、工程架构方面都暴露出了很多不足。 -* PicoClaw 早期 vibe-coding 的快速实现架构在蜂拥而至的 PR 面前会迅速变成“屎山”和冲突地狱。 -* 为尽快合并 PR,未充分验证社区开发者的能力,也没有提供合并指导规范,过早给予 write 权限,在上面架构问题下更暴露出问题。 -* 忙于以上 PR 协调问题,拖后了文档和宣发进度。特别是宣发问题,被不放春节假的海外开发者项目 zeroclaw 趁虚而入。 -* ⚠️ **警惕币圈!** 尤其是 pump.fun 空气币,不要认领参与! - -> **会议核心任务**:本次周会主要需要划分项目板块,认领板块负责人,制订下周计划。以下内容社区开发者可以继续添加遗漏的地方。 - ---- - -## 开发板块 - -### 仓库管理 -* 新建 `dev` 分支,`main` 分支推送严格化。 -* 完善 `CONTRIBUTING.md`。 -* **时区审核分工**: - * GMT+8 附近时区审核(中国) - * GMT+0 附近时区审核(欧洲):**Huaaudio** - * GMT-8 附近时区审核(美洲) -* 仓库权限申请:联系 **zepan** 审核。 -* Readme 中公布本次会议的分工人员表格,方便开发者找寻对应人员审核。 - -### Provider(负责人:daming) -* **进度**:已重构完成。 -* **计划**: - * 梳理支持和计划支持的 provider 协议列表及进度计划。 - * **插件系统探索**:go 原生插件?(参考 [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin)) - * **优化思路**:现在各种系统的 LLM provider 都在重复造轮子,而且每新增一个 provider 都得再改代码、重新发版才能支持。应该把专业的事交给专业的组件来负责。我开了个新的开源项目——`open-next-router`,采用 nginx 原子化配置的思想,新增 provider 无需改代码,新增配置文件即可支持,提供了 go 的 sdk 包,可快速接入项目。PicoClaw 接入后可更聚焦于 agent 的实现而不是各种上游 provider 的适配,就能快其它 claw 一步。 - -### Channels(负责人:daming) -* **进度**:正在重构。 -* **计划**: - * 梳理支持和计划支持的 channel 协议列表及进度计划。 - * **附件支持讨论**:音频、视频、文件。 - * 附件的生命周期应该由谁管理?channel 应该只负责下载文件,然后交由 Agent 消费完成后管理生命周期? - * 音频转文字是否要迁移到 agent 层?或者说附件应该在哪一层被处理? - * 发送附件的方法如何拓展?添加新的方法?拓展原有 Message? - * 群友建议的 **skill加channel**?(参考 [nanoclaw skill](https://github.com/qwibitai/nanoclaw/blob/main/.claude/skills/add-telegram/SKILL.md)) - * **插件系统讨论**。 - * **架构优化**: - * 抽离公共的 HTTP 服务器,采用 WebHook 通信的 channel 通过复用公共的服务器来节省资源和端口。 - * Websocket 支持。 - * 将路由相关字段(`peer_kind`、`peer_id`)从 metadata 中提升为 `InboundMessage` 的结构体字段。 - * **状态管理**:聊天记录应该由 channel 管理还是 agent 管理? - -### Agent(负责人:学欧) -* Agent Loop 机制优化。 -* **记忆系统**:引入 SQLite。 -* **Multi-Agent / Swarm** 支持。 -* **模型能力回退链**:在主模型不支持多模态时,使用多模态模型进行辅助。 - -### Tools(负责人:学欧) -* 整理规范。 -* 插件系统探索。 - -### Heartbeat / Status / Log 等(负责人:daming) -* 完善心跳、状态和日志监控。 - -### Skill -* 搜索 skill 的 skill,已合并 PR:[PR #332](https://github.com/sipeed/picoclaw/pull/332)。 -* **安全与维护**:探讨 skill 的维护和安全性问题,防范目前常见的投毒现象。 - -### MCP(负责人:evo) -* **功能实现**:已有 PR [#376](https://github.com/sipeed/picoclaw/pull/376)、[#282](https://github.com/sipeed/picoclaw/pull/282)。 -* 安卓手机操作支持。 -* 浏览器操作 (`webmcp?` `action book?`):已有相关 PR ([agent-browser-tool](https://github.com/sipeed/picoclaw/tree/feat/agent-browser-tool))。 - -### 占用/效率优化(负责人:学欧) -* **目标**:优化内存占用与执行效率,希望控制在 **20M 以内**。 -* **分析**:分析各个版本之间的内存占用变化,分析各个模块的内存占用情况。 -* **裁剪**:裁剪出最小版本,用于宣发。 - -### Security -* 响应并修复安全机构发送的漏洞警示。 -* 参考 openclaw 等现有仓库的安全措施,加固 PicoClaw。 - -### AI CI(负责人:政宇) -* 完善仓库的 CI 流程。 -* 加入 AI review 等自动化流程。 -* 完善发布流程、测试项目、release note、breaking change 记录。 -* 根目录加上 `CLAUDE.md`? -* 增加 `loongarch` & `deb/rpm` 支持。 - -### UX Testing -* 对 release 版进行一般性测试。 -* 站在小白用户角度对使用交互提出意见建议,比如完善 PicoClaw onboard 流程。 -* 展示性优化:比如启动时刷屏 ascii-art 的 PicoClaw 标识,增加用户拍摄视频时的辨识度。 - -### 文档工作 -* 仓库 Readme 美化,仓库文档整理、规范。 -* 整理所有 Channel、Provider 的实现支持列表。 -* 针对小白用户的各个 Provider、Channel 详细手把手教程文档。 -* 建设 Wiki 页面(deepwiki?)。 - ---- - -## Release 待办事项 (Checklist) -- [ ] Provider -- [ ] Channel -- [ ] Agent -- [ ] Swarm -- [ ] Security -- [ ] MCP:浏览器 -- [ ] 文档 -- [ ] Logo -- [ ] Metadata 问题解决 - ---- - -## 关于插件系统测试方案(补充记录) -测试了以下几种方案: -1. **内置的 plugin 模块**:不考虑。不支持 Windows 等平台 ([plugin](https://pkg.go.dev/plugin@go1.26.0))。 -2. **hashicorp/go-plugin**:不考虑。占用资源过大,固件都增加了 20~30M。 -3. **net/rpc**(client-server 模式): - * **优点**:支持热加载,插件可以保存运行状态。 - * **缺点**:资源消耗较多(内存约增加 5M+,每个插件大小 10+M),每个插件占用一个端口,不太优雅。 -4. **encoding/gob**(编译为可执行程序,由主程序调用并获取返回值): - * **优点**:支持热加载,消耗资源相对较少(测试固件大小增加了 376KB,内存消耗增加了 640KB)。 - * **缺点**:无法保存运行状态(应该可以用 socket 等方法来优化支持)。 - ---- - -## 宣发板块 - -### 社区运营 -* **宣发物料/策划**:负责人 **zepan**,再寻求 1~2 位有网感的社区成员。 - * 制作标准 Logo, Slogan。 - * 制作具有传播性的图文/视频等。 - * 策划互动性、传播性强的用户活动,产生用户内容。 - * KOL 建联等其它宣发手段。 -* **微信群运营**:负责人 **zepan**。 -* **推特运营**:负责人 **zepan**。 -* **Discord运营**:负责人 **OsmiumOP**;需要再找一个国内开发者盯一下,会给予 admin 权限。 -* **其他渠道开拓**:小红书、知乎、Reddit? -* **Go社区联络大使**:负责人 **卓**。 - ---- - -## 中期 TODO - -* **桌面应用 / 安卓 APP** - * 架构讨论:C/S 还是单程序?接口文档规范? -* **配套硬件** diff --git a/PicoClaw贡献方向规划.md b/PicoClaw贡献方向规划.md deleted file mode 100644 index 0b4ea40b8..000000000 --- a/PicoClaw贡献方向规划.md +++ /dev/null @@ -1,108 +0,0 @@ -# PicoClaw 贡献方向规划(3月1日更新) - -## 个人情况 - -- Go 开发者,会 Python,在学 AI Agent -- 已合并 PR:#173(多bug修复)、#186(安全加固) -- 已提交 PR:#732(JSONL session store,等待 review) -- 已关闭 PR:#719(SQLite 方案,被维护者建议改用 JSONL) - ---- - -## 项目当前态势(3月1日) - -### 已完成的重构 -- Provider 重构:daming #492 — 完成 -- Channel 重构 Phase 1:alexhoshina #662 — 完成 -- Channel 重构 Phase 2:alexhoshina #877 (10,926行) — 2月27日合并 -- Migrate 重构:lxowalle #910 — 2月28日合并 - -### 正在进行的重构 -- **Tools 系统重构**:lxowalle PR #846(50个文件)— OPEN -- **Plugin 系统**:gh-xj PR #936-939(4个PR系列)— OPEN -- **Agent 系统重构**:alexhoshina Issue #772(roadmap)— 只有 issue,还没有 PR - -### 我的行动记录 -- 2月24日:在 #772 评论,将 PR #732 定位为 Agent 重构的 memory 子任务 -- 3月1日:在 #295 评论,提出模型路由设计方案 - ---- - -## 战略方向 - -### 方向 1:智能模型路由(#295)— 主攻 ✅ 代码已完成 - -**为什么选这个**: -1. Zepan(创始人)亲自创建的 issue,roadmap 标签 -2. 有大量社区讨论但零 PR -3. 独立模块 `pkg/routing/`,不碰任何重构区文件 -4. 面试价值极高 - -**已完成(分支 feat/model-routing)**: -- `pkg/routing/features.go` — ExtractFeatures:5维结构评分,纯语言无关 -- `pkg/routing/classifier.go` — Classifier 接口 + RuleClassifier(加权求和,上限 1.0) -- `pkg/routing/router.go` — Router:SelectModel,阈值默认 0.35 -- `pkg/routing/router_test.go` — 34 个测试,全部通过 -- `pkg/config/config.go` — RoutingConfig 添加到 AgentDefaults -- `pkg/agent/instance.go` — 预计算 Router + LightCandidates -- `pkg/agent/loop.go` — selectCandidates helper,turn 级别粘性路由 - -**3 个 commit,773 行新增,33 行修改,0 个新依赖** - -**配置**: -```json -{ - "agents": { - "defaults": { - "model": "claude-sonnet-4-6", - "routing": { - "enabled": true, - "light_model": "gemini-flash", - "threshold": 0.35 - } - } - } -} -``` - -**下一步**:向上游 push 并开 PR,PR body 引用 issue #295 - -### 方向 2:JSONL Store 集成 — 等待时机 - -PR #732 已提交。等 Tools 重构 (#846) 合并后再做集成 PR。 -已在 #772 评论建立关联。 - -### 方向 3:sessions CLI 子命令(#575)— 备选快速 PR - -如果需要一个快速能合并的 PR 来积累信任: -- `picoclaw sessions list/clear/export` -- 不碰任何重构区文件 -- 实用性强 - ---- - -## 需要避开的区域 - -| 区域 | 原因 | -|------|------| -| Tools 系统 | lxowalle PR #846 正在重构 | -| Plugin 系统 | gh-xj PR #936-939 正在做 | -| Channel 任何东西 | alexhoshina 刚完成大重构 | -| Provider 配置 | daming 已定型 | -| MCP | 两个竞争 PR (#282, #376) | -| Hooks 基础 | gh-xj #936 包含 pkg/hooks/ | -| AgentLoop 拆分 | SaiBalusu-usf PR #699 | -| Tool pair 修复 | QuietyAwe PR #871 | - ---- - -## 关键人物(更新) - -| 人 | GitHub | 角色 | 最近活动 | -|---|--------|------|---------| -| Zepan | @Zepan | 创始人 | #806 WebUI issue | -| daming | @yinwm | Provider/审核 | 审核 PR #877 | -| alexhoshina | @alexhoshina | Channel+Agent 重构 | #877 合并,#772 发起 | -| lxowalle | @lxowalle | Tools+审核 | #846 Tools重构中 | -| gh-xj | @gh-xj | Plugin 系统 | #936-939 四个 PR | -| nikolasdehor | @nikolasdehor | 社区活跃评论者 | 每个 issue 都有他 | From b84adacc2f302aa68c3ccd88bc5815ff51904273 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Fri, 6 Mar 2026 13:10:20 +0800 Subject: [PATCH 63/73] fix(routing): address review feedback on CJK estimation and observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CJK token estimation: replace flat rune_count/3 with script-aware counting — CJK runes (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) count as 1 token each, non-CJK runes at /4. This fixes a 3x underestimate for Chinese/Japanese/Korean text that could incorrectly route complex CJK messages to the light model. 2. Routing observability: SelectModel now returns the computed score as a third value. selectCandidates logs the score on both paths — Info level for light model selection, Debug level for primary model selection. 3. Added tests: TestExtractFeatures_TokenEstimate_Mixed (CJK+ASCII mix), TestRouter_SelectModel_ReturnsScore. Addresses review feedback from @mingmxren. --- pkg/agent/loop.go | 9 ++++++- pkg/routing/features.go | 29 +++++++++++++------- pkg/routing/router.go | 15 ++++++----- pkg/routing/router_test.go | 54 ++++++++++++++++++++++++++------------ 4 files changed, 72 insertions(+), 35 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5e68e4931..132bb3c98 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1192,8 +1192,14 @@ func (al *AgentLoop) selectCandidates( return agent.Candidates, agent.Model } - _, usedLight := agent.Router.SelectModel(userMsg, history, agent.Model) + _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) if !usedLight { + logger.DebugCF("agent", "Model routing: primary model selected", + map[string]any{ + "agent_id": agent.ID, + "score": score, + "threshold": agent.Router.Threshold(), + }) return agent.Candidates, agent.Model } @@ -1201,6 +1207,7 @@ func (al *AgentLoop) selectCandidates( map[string]any{ "agent_id": agent.ID, "light_model": agent.Router.LightModel(), + "score": score, "threshold": agent.Router.Threshold(), }) return agent.LightCandidates, agent.Router.LightModel() diff --git a/pkg/routing/features.go b/pkg/routing/features.go index 4fa1c5b6c..c371e21aa 100644 --- a/pkg/routing/features.go +++ b/pkg/routing/features.go @@ -15,9 +15,9 @@ const lookbackWindow = 6 // Every dimension is language-agnostic by construction — no keyword or pattern matching // against natural-language content. This ensures consistent routing for all locales. type Features struct { - // TokenEstimate is a conservative proxy for token count. - // Computed as utf8.RuneCountInString(msg) / 3, which handles CJK characters - // (each rune ≈ 1 token for CJK, ≈ 0.25 tokens for ASCII) without any API call. + // TokenEstimate is a proxy for token count. + // CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each. + // This avoids API calls while giving accurate estimates for all scripts. TokenEstimate int // CodeBlockCount is the number of fenced code blocks (``` pairs) in the message. @@ -50,14 +50,23 @@ func ExtractFeatures(msg string, history []providers.Message) Features { } } -// estimateTokens returns a conservative token count proxy. -// Using rune count / 3 rather than / 4 because CJK characters each map to -// roughly one token, while ASCII words average ~1.3 chars/token. Dividing -// by 3 is a safe middle ground that slightly over-estimates for Latin text -// (errs toward routing to the heavy model) and is accurate for CJK. +// estimateTokens returns a token count proxy that handles both CJK and Latin text. +// CJK runes (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) map to roughly one +// token each, while non-CJK runes average ~0.25 tokens/rune (≈4 chars per token +// for English). Splitting the count this way avoids the 3x underestimation that a +// flat rune_count/3 would produce for Chinese, Japanese, and Korean text. func estimateTokens(msg string) int { - rc := utf8.RuneCountInString(msg) - return rc / 3 + total := utf8.RuneCountInString(msg) + if total == 0 { + return 0 + } + cjk := 0 + for _, r := range msg { + if r >= 0x2E80 && r <= 0x9FFF || r >= 0xF900 && r <= 0xFAFF || r >= 0xAC00 && r <= 0xD7AF { + cjk++ + } + } + return cjk + (total-cjk)/4 } // countCodeBlocks counts the number of complete fenced code blocks. diff --git a/pkg/routing/router.go b/pkg/routing/router.go index 78092b106..b1fa347e9 100644 --- a/pkg/routing/router.go +++ b/pkg/routing/router.go @@ -50,10 +50,11 @@ func newWithClassifier(cfg RouterConfig, c Classifier) *Router { return &Router{cfg: cfg, classifier: c} } -// SelectModel returns the model to use for this conversation turn. +// SelectModel returns the model to use for this conversation turn along with +// the computed complexity score (for logging and debugging). // -// - If score < cfg.Threshold: returns (cfg.LightModel, true) -// - Otherwise: returns (primaryModel, false) +// - If score < cfg.Threshold: returns (cfg.LightModel, true, score) +// - Otherwise: returns (primaryModel, false, score) // // The caller is responsible for resolving the returned model name into // provider candidates (see AgentInstance.LightCandidates). @@ -61,13 +62,13 @@ func (r *Router) SelectModel( msg string, history []providers.Message, primaryModel string, -) (model string, usedLight bool) { +) (model string, usedLight bool, score float64) { features := ExtractFeatures(msg, history) - score := r.classifier.Score(features) + score = r.classifier.Score(features) if score < r.cfg.Threshold { - return r.cfg.LightModel, true + return r.cfg.LightModel, true, score } - return primaryModel, false + return primaryModel, false, score } // LightModel returns the configured light model name. diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go index 267200c2e..2824d10ab 100644 --- a/pkg/routing/router_test.go +++ b/pkg/routing/router_test.go @@ -29,16 +29,16 @@ func TestExtractFeatures_EmptyMessage(t *testing.T) { } func TestExtractFeatures_TokenEstimate(t *testing.T) { - // 30 ASCII chars / 3 = 10 tokens + // 30 ASCII runes: 0 CJK + 30/4 = 7 tokens msg := strings.Repeat("a", 30) f := ExtractFeatures(msg, nil) - if f.TokenEstimate != 10 { - t.Errorf("TokenEstimate: got %d, want 10", f.TokenEstimate) + if f.TokenEstimate != 7 { + t.Errorf("TokenEstimate: got %d, want 7", f.TokenEstimate) } } func TestExtractFeatures_TokenEstimate_CJK(t *testing.T) { - // 9 CJK runes (U+4F60 U+597D U+4E16 U+754C × 2 + U+4F60) / 3 = 3 tokens. + // 9 CJK runes → 9 tokens (each CJK rune ≈ 1 token). // Using a rune slice literal avoids CJK string literals in source. msg := string([]rune{ 0x4F60, 0x597D, 0x4E16, 0x754C, @@ -46,8 +46,17 @@ func TestExtractFeatures_TokenEstimate_CJK(t *testing.T) { 0x4F60, }) f := ExtractFeatures(msg, nil) - if f.TokenEstimate != 3 { - t.Errorf("CJK TokenEstimate: got %d, want 3", f.TokenEstimate) + if f.TokenEstimate != 9 { + t.Errorf("CJK TokenEstimate: got %d, want 9", f.TokenEstimate) + } +} + +func TestExtractFeatures_TokenEstimate_Mixed(t *testing.T) { + // Mixed: 4 CJK runes + 8 ASCII runes → 4 + 8/4 = 6 tokens. + msg := string([]rune{0x4F60, 0x597D, 0x4E16, 0x754C}) + "hello ok" + f := ExtractFeatures(msg, nil) + if f.TokenEstimate != 6 { + t.Errorf("Mixed TokenEstimate: got %d, want 6", f.TokenEstimate) } } @@ -249,7 +258,7 @@ func TestRouter_NegativeThresholdFallsBackToDefault(t *testing.T) { func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) msg := "hi" - model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { t.Error("simple message: expected light model to be selected") } @@ -261,7 +270,7 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) msg := "```go\nfmt.Println(\"hello\")\n```" - model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("code block: expected primary model to be selected") } @@ -273,7 +282,7 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) msg := "can you analyze this? data:image/png;base64,abc123" - model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("attachment: expected primary model to be selected") } @@ -286,7 +295,7 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35}) // >200 token estimate: 210 * 3 = 630 chars msg := strings.Repeat("word ", 210) - model, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("long message: expected primary model to be selected") } @@ -304,7 +313,7 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}}, } msg := "ok" - _, usedLight := r.SelectModel(msg, history, "claude-sonnet-4-6") + _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6") if !usedLight { t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)") } @@ -320,7 +329,7 @@ func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { } // ~55 tokens * 3 = 165 chars msg := strings.Repeat("word ", 55) - _, usedLight := r.SelectModel(msg, history, "claude-sonnet-4-6") + _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6") if usedLight { t.Error("tool chain + medium message: expected primary model (score >= 0.35)") } @@ -330,7 +339,7 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) { // Very low threshold: even a short message triggers heavy model r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.05}) msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05 - _, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { t.Error("low threshold: medium message should use primary model") } @@ -340,7 +349,7 @@ func TestRouter_SelectModel_HighThreshold(t *testing.T) { // Very high threshold: even code blocks route to light r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99}) msg := "```go\nfmt.Println()\n```" - _, usedLight := r.SelectModel(msg, nil, "claude-sonnet-4-6") + _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { t.Error("very high threshold: code block (0.40) should route to light model") } @@ -364,7 +373,7 @@ func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.2}, ) - _, usedLight := r.SelectModel("anything", nil, "heavy") + _, usedLight, _ := r.SelectModel("anything", nil, "heavy") if !usedLight { t.Error("low score with custom classifier: expected light model") } @@ -375,7 +384,7 @@ func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.8}, ) - _, usedLight := r.SelectModel("anything", nil, "heavy") + _, usedLight, _ := r.SelectModel("anything", nil, "heavy") if usedLight { t.Error("high score with custom classifier: expected primary model") } @@ -387,8 +396,19 @@ func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { RouterConfig{LightModel: "light", Threshold: 0.5}, &fixedScoreClassifier{score: 0.5}, ) - _, usedLight := r.SelectModel("anything", nil, "heavy") + _, usedLight, _ := r.SelectModel("anything", nil, "heavy") if usedLight { t.Error("score == threshold: expected primary model (>= threshold → primary)") } } + +func TestRouter_SelectModel_ReturnsScore(t *testing.T) { + r := newWithClassifier( + RouterConfig{LightModel: "light", Threshold: 0.5}, + &fixedScoreClassifier{score: 0.42}, + ) + _, _, score := r.SelectModel("anything", nil, "heavy") + if score != 0.42 { + t.Errorf("score: got %f, want 0.42", score) + } +} From 7df7e0495cd6225f51ac50c11fd74cb5a3bc5e1e Mon Sep 17 00:00:00 2001 From: Yajun Yao Date: Fri, 6 Mar 2026 16:04:31 +0800 Subject: [PATCH 64/73] fix deepseek-chat bug (#1066) Co-authored-by: FantasticCode2019 <1443996278@qq.com> --- pkg/agent/context.go | 55 ++++++++++++++++++++++++++++- pkg/agent/context_test.go | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index d84aea627..719b0cb6d 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -605,7 +605,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } } - return sanitized + // Second pass: ensure every assistant message with tool_calls has matching + // tool result messages following it. This is required by strict providers + // like DeepSeek that enforce: "An assistant message with 'tool_calls' must + // be followed by tool messages responding to each 'tool_call_id'." + final := make([]providers.Message, 0, len(sanitized)) + for i := 0; i < len(sanitized); i++ { + msg := sanitized[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + // Collect expected tool_call IDs + expected := make(map[string]bool, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + expected[tc.ID] = false + } + + // Check following messages for matching tool results + toolMsgCount := 0 + for j := i + 1; j < len(sanitized); j++ { + if sanitized[j].Role != "tool" { + break + } + toolMsgCount++ + if _, exists := expected[sanitized[j].ToolCallID]; exists { + expected[sanitized[j].ToolCallID] = true + } + } + + // If any tool_call_id is missing, drop this assistant message and its partial tool messages + allFound := true + for toolCallID, found := range expected { + if !found { + allFound = false + logger.DebugCF( + "agent", + "Dropping assistant message with incomplete tool results", + map[string]any{ + "missing_tool_call_id": toolCallID, + "expected_count": len(expected), + "found_count": toolMsgCount, + }, + ) + break + } + } + + if !allFound { + // Skip this assistant message and its tool messages + i += toolMsgCount + continue + } + } + final = append(final, msg) + } + + return final } func (cb *ContextBuilder) AddToolResult( diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index e023c9c30..5756ed911 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -207,3 +207,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) { } } } + +// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation +// that ensures assistant messages with tool_calls have ALL matching tool results. +// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be +// followed by tool messages responding to each 'tool_call_id'." +func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) { + // Assistant expects tool results for both A and B, but only A is present + history := []providers.Message{ + msg("user", "do two things"), + assistantWithTools("A", "B"), + toolResult("A"), + // toolResult("B") is missing - this would cause DeepSeek to fail + msg("user", "next question"), + msg("assistant", "answer"), + } + + result := sanitizeHistoryForProvider(history) + // The assistant message with incomplete tool results should be dropped, + // along with its partial tool result. The remaining messages are: + // user ("do two things"), user ("next question"), assistant ("answer") + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "user", "assistant") +} + +// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where +// an assistant message has tool_calls but no tool results follow at all. +func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) { + history := []providers.Message{ + msg("user", "do something"), + assistantWithTools("A"), + // No tool results at all + msg("user", "hello"), + msg("assistant", "hi"), + } + + result := sanitizeHistoryForProvider(history) + // The assistant message with no tool results should be dropped. + // Remaining: user ("do something"), user ("hello"), assistant ("hi") + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "user", "assistant") +} + +// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that +// incomplete tool results in the middle of a conversation are properly handled. +func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) { + history := []providers.Message{ + msg("user", "first"), + assistantWithTools("A"), + toolResult("A"), + msg("assistant", "done"), + msg("user", "second"), + assistantWithTools("B", "C"), + toolResult("B"), + // toolResult("C") is missing + msg("user", "third"), + assistantWithTools("D"), + toolResult("D"), + msg("assistant", "all done"), + } + + result := sanitizeHistoryForProvider(history) + // First round is complete (user, assistant+tools, tool, assistant), + // second round is incomplete and dropped (assistant+tools, partial tool), + // third round is complete (user, assistant+tools, tool, assistant). + // Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant + if len(result) != 9 { + t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant") +} From c3c293297df5f42f95162bd36b848d36c49f76d5 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:27:43 +0800 Subject: [PATCH 65/73] feat: add upload_tos toggle to release workflow (#1183) Add a boolean input (default: true) to control whether release artifacts are uploaded to Volcengine TOS. Co-authored-by: Claude Opus 4.6 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6566afe96..0edd29f22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ on: required: false type: boolean default: false + upload_tos: + description: "Upload to Volcengine TOS" + required: false + type: boolean + default: true jobs: create-tag: @@ -104,6 +109,7 @@ jobs: upload-tos: name: Upload to TOS needs: release + if: ${{ inputs.upload_tos }} uses: ./.github/workflows/upload-tos.yml with: tag: ${{ inputs.tag }} From b716b8a053a4d1e163fc43f6832aa081fb748152 Mon Sep 17 00:00:00 2001 From: Ming Date: Fri, 6 Mar 2026 17:31:40 +0800 Subject: [PATCH 66/73] feat(commands): centralized command registry with sub-command routing (#959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(commands): Session management [Phase 1/2] command centralization and registration * docs: add design for command registry post-review fixes Documents the architecture decisions for fixing 5 Important issues from code review: SubCommand pattern, Deps struct, command-group files, Executor caching, and Telegram registration dedup. Co-Authored-By: Claude Opus 4.6 * feat(commands): add SubCommand type and EffectiveUsage method Introduce SubCommand struct for declaring sub-commands structurally within a parent command Definition. The EffectiveUsage() method auto-generates usage strings from sub-command names and args, preventing drift between help text and actual handler behavior. Co-Authored-By: Claude Opus 4.6 * feat(commands): add Deps struct and secondToken helper, remove dead contains() Co-Authored-By: Claude Opus 4.6 * feat(commands): add sub-command routing to Executor Uses Registry.Lookup for O(1) command dispatch instead of iterating all definitions. Definitions with SubCommands are routed to matching sub-command handlers. Missing or unknown sub-commands reply with auto-generated usage. Co-Authored-By: Claude Opus 4.6 * refactor(commands): split into command-group files with Deps injection Extract show/list/start/help into individual cmd_*.go files. Replace config.Config parameter with Deps struct for runtime data. Restore /show agents and /list agents sub-commands. Use EffectiveUsage for auto-generated help text. Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper until Task 5 fully wires the Deps fields. Co-Authored-By: Claude Opus 4.6 * perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks Create Executor once in NewAgentLoop instead of per-message. Deps closures capture AgentLoop pointer for late-bound access to channelManager and runtime agent model. Co-Authored-By: Claude Opus 4.6 * fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only Co-Authored-By: Claude Opus 4.6 * chore(commands): restore Outcome comments and annotate Deps.Config Co-Authored-By: Claude Opus 4.6 * refactor(commands): consolidate /switch into commands package, fix ! prefix Move /switch model and /switch channel handling from inline loop.go logic into cmd_switch.go using the SubCommand + Deps pattern. This removes the OutcomePassthrough branch in handleCommand entirely. Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix so that "!" prefixed commands are correctly routed to the Executor. Co-Authored-By: Claude Opus 4.6 * chore: add docs/plans to .gitignore and untrack existing files Co-Authored-By: Claude Opus 4.6 * refactor(commands): address code review findings - Remove dead ExecuteResult.Reply field and unused branch in loop.go - Extract shared agentsHandler for /show agents and /list agents - Remove redundant firstToken/secondToken (use nthToken instead) - Simplify Telegram startup: pass BuiltinDefinitions directly - Centralize req.Reply nil guard in executeDefinition - Extract unavailableMsg constant (was duplicated 5 times) - Remove unused MessageID from Request - Remove stale "reserved for Phase 2" comment on Deps.Config Co-Authored-By: Claude Opus 4.6 * refactor(commands): replace Deps with per-request Runtime Separate stateless Registry (cached on AgentLoop) from per-request Runtime (passed to handlers at execution time). This enables future session management features to inject per-request context without modifying the command registry. - Rename Deps → Runtime, move to runtime.go - Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error - NewExecutor now takes (registry, runtime) — executor is created per-request - BuiltinDefinitions() no longer takes parameters (stateless) - AgentLoop caches cmdRegistry, builds Runtime via buildRuntime() - Update all cmd_*.go handlers and tests Co-Authored-By: Claude Opus 4.6 * style: fix gci import grouping and godoc formatting Co-Authored-By: Claude Opus 4.6 * fix(onboard): skip legacy AGENT.md when copying embedded workspace templates The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md (current). copyEmbeddedToTarget was copying both, causing the test TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md during the walk to match the expected behavior. Co-Authored-By: Claude Opus 4.6 * refactor(agent): address self-review comments on loop.go - Move cmdRegistry init into struct literal (review comment #11) - Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12) - Add comment to default switch case explaining passthrough (review comment #13) Co-Authored-By: Claude Opus 4.6 * refactor(commands): address code review findings on naming and correctness - Rename dispatcher.go → request.go (no Dispatcher type remains) - Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command) - Add modelMu to protect AgentInstance.Model writes in SwitchModel - Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions() - Fix SwitchChannel message: validation-only callback should not say "Switched" - Propagate Reply errors in executor instead of discarding with _ = - Add HasCommandPrefix unit test Co-Authored-By: Claude Opus 4.6 * refactor(onboard): extract legacy filename to constant Co-Authored-By: Claude Opus 4.6 * fix(agent): handle commands before route error check Move handleCommand() before the routeErr gate so global commands (/help, /show, /switch) remain available even when routing fails. Context-dependent commands that need a routed agent will report "unavailable" through their nil-Runtime guards. Co-Authored-By: Claude Opus 4.6 * revert: remove unnecessary AGENT.md skip in onboard Reverts 02d0c04 and 74deae1. The test failure was caused by a local leftover workspace/AGENT.md file (gitignored but embedded by go:embed). Deleting the local file fixes the root cause; the code-level skip was never needed. Co-Authored-By: Claude Opus 4.6 * fix: executeDefinition Unknown option * fix(agent): use routed agent for model commands, restore Telegram command diff - Remove modelMu: message processing is serial, no concurrent writes - Pass routed agent to handleCommand/buildCommandsRuntime instead of always using default agent - GetModelInfo/SwitchModel are nil when agent is nil (route failed), handlers reply "unavailable" - Restore GetMyCommands + slices.Equal check before SetMyCommands to avoid unnecessary Telegram API calls on restart Co-Authored-By: Claude Opus 4.6 * fix(commands): remove unintended config mutation in SwitchModel SwitchModel should only update the routed agent's runtime Model field. Writing to cfg.Agents.Defaults.ModelName was a behavioral change that corrupts the default agent config when switching a non-default agent. Co-Authored-By: Claude Opus 4.6 * refactor(commands): move /switch channel to /check channel /switch channel only validates availability, not actually switching. Rename to /check channel to match actual behavior. /switch channel now shows a redirect message pointing users to the new command. Addresses review feedback from yinwm on PR #959. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 3 + README.md | 17 ++ README.zh.md | 17 ++ go.mod | 2 +- pkg/agent/loop.go | 233 ++++++++------- pkg/agent/loop_test.go | 216 ++++++++++++++ pkg/channels/interfaces.go | 13 +- pkg/channels/interfaces_command_test.go | 16 + pkg/channels/telegram/command_registration.go | 116 ++++++++ .../telegram/command_registration_test.go | 96 ++++++ pkg/channels/telegram/telegram.go | 170 +++++------ pkg/channels/telegram/telegram_commands.go | 156 ---------- .../telegram/telegram_dispatch_test.go | 52 ++++ .../telegram_group_command_filter_test.go | 147 +++++++++ .../whatsapp/whatsapp_command_test.go | 41 +++ .../whatsapp_native/whatsapp_command_test.go | 56 ++++ pkg/commands/builtin.go | 16 + pkg/commands/builtin_test.go | 145 +++++++++ pkg/commands/cmd_check.go | 33 +++ pkg/commands/cmd_help.go | 44 +++ pkg/commands/cmd_list.go | 52 ++++ pkg/commands/cmd_show.go | 38 +++ pkg/commands/cmd_start.go | 14 + pkg/commands/cmd_switch.go | 42 +++ pkg/commands/cmd_switch_test.go | 279 ++++++++++++++++++ pkg/commands/definition.go | 48 +++ pkg/commands/definition_test.go | 41 +++ pkg/commands/executor.go | 89 ++++++ pkg/commands/executor_test.go | 260 ++++++++++++++++ pkg/commands/handler_agents.go | 21 ++ pkg/commands/registry.go | 55 ++++ pkg/commands/registry_test.go | 49 +++ pkg/commands/request.go | 75 +++++ pkg/commands/request_test.go | 28 ++ pkg/commands/runtime.go | 16 + pkg/commands/show_list_handlers_test.go | 85 ++++++ 36 files changed, 2428 insertions(+), 353 deletions(-) create mode 100644 pkg/channels/interfaces_command_test.go create mode 100644 pkg/channels/telegram/command_registration.go create mode 100644 pkg/channels/telegram/command_registration_test.go delete mode 100644 pkg/channels/telegram/telegram_commands.go create mode 100644 pkg/channels/telegram/telegram_dispatch_test.go create mode 100644 pkg/channels/telegram/telegram_group_command_filter_test.go create mode 100644 pkg/channels/whatsapp/whatsapp_command_test.go create mode 100644 pkg/channels/whatsapp_native/whatsapp_command_test.go create mode 100644 pkg/commands/builtin.go create mode 100644 pkg/commands/builtin_test.go create mode 100644 pkg/commands/cmd_check.go create mode 100644 pkg/commands/cmd_help.go create mode 100644 pkg/commands/cmd_list.go create mode 100644 pkg/commands/cmd_show.go create mode 100644 pkg/commands/cmd_start.go create mode 100644 pkg/commands/cmd_switch.go create mode 100644 pkg/commands/cmd_switch_test.go create mode 100644 pkg/commands/definition.go create mode 100644 pkg/commands/definition_test.go create mode 100644 pkg/commands/executor.go create mode 100644 pkg/commands/executor_test.go create mode 100644 pkg/commands/handler_agents.go create mode 100644 pkg/commands/registry.go create mode 100644 pkg/commands/registry_test.go create mode 100644 pkg/commands/request.go create mode 100644 pkg/commands/request_test.go create mode 100644 pkg/commands/runtime.go create mode 100644 pkg/commands/show_list_handlers_test.go diff --git a/.gitignore b/.gitignore index 02ef18d1f..a52b8d25a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ ralph/ .ralph/ tasks/ +# Plans +docs/plans/ + # Editors .vscode/ .idea/ diff --git a/README.md b/README.md index 7a31f9364..3774055b4 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,13 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We picoclaw gateway ``` +**4. Telegram command menu (auto-registered at startup)** + +PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync. +Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. + +If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. +

@@ -750,6 +757,12 @@ For advanced/test setups, you can override the builtin skills root with: export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Unified Command Execution Policy + +- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. +- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. +- Unknown slash command (for example `/foo`) passes through to normal LLM processing. +- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. @@ -1205,6 +1218,10 @@ picoclaw agent -m "Hello" "model": "anthropic/claude-opus-4-5" } }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" diff --git a/README.zh.md b/README.zh.md index bd90173f9..dc32b67e0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -307,6 +307,13 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | +### Telegram 命令注册(启动时自动同步) + +PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 + +如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 + ## ClawdChat 加入 Agent 社交网络 只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 @@ -376,6 +383,12 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### 统一命令执行策略 + +- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 +- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 +- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 +- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 ### 心跳 / 周期性任务 (Heartbeat) PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: @@ -715,6 +728,10 @@ picoclaw agent -m "你好" "model": "anthropic/claude-opus-4-5" } }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, "providers": { "openrouter": { "api_key": "sk-or-v1-xxx" diff --git a/go.mod b/go.mod index 238bd405c..6fa3a900c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gdamore/tcell/v2 v2.13.8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.0 @@ -37,7 +38,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/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/pkg/agent/loop.go b/pkg/agent/loop.go index 132bb3c98..966668227 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -21,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" @@ -46,6 +47,7 @@ type AgentLoop struct { channelManager *channels.Manager mediaStore media.MediaStore transcriber voice.Transcriber + cmdRegistry *commands.Registry } // processOptions configures how a message is processed @@ -61,7 +63,15 @@ type processOptions struct { 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." +const ( + defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." + sessionKeyAgentPrefix = "agent:" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + metadataKeyTeamID = "team_id" + metadataKeyParentPeerKind = "parent_peer_kind" + metadataKeyParentPeerID = "parent_peer_id" +) func NewAgentLoop( cfg *config.Config, @@ -84,14 +94,17 @@ func NewAgentLoop( stateManager = state.NewManager(defaultAgent.Workspace) } - return &AgentLoop{ + al := &AgentLoop{ bus: msgBus, cfg: cfg, registry: registry, state: stateManager, summarizing: sync.Map{}, fallback: fallbackChain, + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), } + + return al } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). @@ -549,27 +562,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.processSystemMessage(ctx, msg) } - // Check for commands - if response, handled := al.handleCommand(ctx, msg); handled { + route, agent, routeErr := al.resolveMessageRoute(msg) + + // Commands are checked before requiring a successful route. + // Global commands (/help, /show, /switch) work even when routing fails; + // context-dependent commands check their own Runtime fields and report + // "unavailable" when the required capability is nil. + if response, handled := al.handleCommand(ctx, msg, agent); handled { return response, nil } - // Route to determine agent and session key - route := al.registry.ResolveRoute(routing.RouteInput{ - Channel: msg.Channel, - AccountID: msg.Metadata["account_id"], - Peer: extractPeer(msg), - ParentPeer: extractParentPeer(msg), - GuildID: msg.Metadata["guild_id"], - TeamID: msg.Metadata["team_id"], - }) - - agent, ok := al.registry.GetAgent(route.AgentID) - if !ok { - agent = al.registry.GetDefaultAgent() - } - if agent == nil { - return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + if routeErr != nil { + return "", routeErr } // Reset message-tool state for this round so we don't skip publishing due to a previous round. @@ -579,17 +583,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } } - // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron) - sessionKey := route.SessionKey - if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") { - sessionKey = msg.SessionKey - } + // Resolve session key from route, while preserving explicit agent-scoped keys. + scopeKey := resolveScopeKey(route, msg.SessionKey) + sessionKey := scopeKey logger.InfoCF("agent", "Routed message", map[string]any{ - "agent_id": agent.ID, - "session_key": sessionKey, - "matched_by": route.MatchedBy, + "agent_id": agent.ID, + "scope_key": scopeKey, + "session_key": sessionKey, + "matched_by": route.MatchedBy, + "route_agent": route.AgentID, + "route_channel": route.Channel, }) return al.runAgentLoop(ctx, agent, processOptions{ @@ -604,6 +609,34 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) } +func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { + route := al.registry.ResolveRoute(routing.RouteInput{ + Channel: msg.Channel, + AccountID: inboundMetadata(msg, metadataKeyAccountID), + Peer: extractPeer(msg), + ParentPeer: extractParentPeer(msg), + GuildID: inboundMetadata(msg, metadataKeyGuildID), + TeamID: inboundMetadata(msg, metadataKeyTeamID), + }) + + agent, ok := al.registry.GetAgent(route.AgentID) + if !ok { + agent = al.registry.GetDefaultAgent() + } + if agent == nil { + return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + } + + return route, agent, nil +} + +func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { + if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { + return msgSessionKey + } + return route.SessionKey +} + func (al *AgentLoop) processSystemMessage( ctx context.Context, msg bus.InboundMessage, @@ -1504,94 +1537,87 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { return totalChars * 2 / 5 } -func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { - content := strings.TrimSpace(msg.Content) - if !strings.HasPrefix(content, "/") { +func (al *AgentLoop) handleCommand( + ctx context.Context, + msg bus.InboundMessage, + agent *AgentInstance, +) (string, bool) { + if !commands.HasCommandPrefix(msg.Content) { return "", false } - parts := strings.Fields(content) - if len(parts) == 0 { + if al.cmdRegistry == nil { return "", false } - cmd := parts[0] - args := parts[1:] + rt := al.buildCommandsRuntime(agent) + executor := commands.NewExecutor(al.cmdRegistry, rt) - switch cmd { - case "/show": - if len(args) < 1 { - return "Usage: /show [model|channel|agents]", true - } - switch args[0] { - case "model": - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent == nil { - return "No default agent configured", true - } - return fmt.Sprintf("Current model: %s", defaultAgent.Model), true - case "channel": - return fmt.Sprintf("Current channel: %s", msg.Channel), true - case "agents": - agentIDs := al.registry.ListAgentIDs() - return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true - default: - return fmt.Sprintf("Unknown show target: %s", args[0]), true - } + var commandReply string + result := executor.Execute(ctx, commands.Request{ + Channel: msg.Channel, + ChatID: msg.ChatID, + SenderID: msg.SenderID, + Text: msg.Content, + Reply: func(text string) error { + commandReply = text + return nil + }, + }) - case "/list": - if len(args) < 1 { - return "Usage: /list [models|channels|agents]", true + switch result.Outcome { + case commands.OutcomeHandled: + if result.Err != nil { + return mapCommandError(result), true } - switch args[0] { - case "models": - return "Available models: configured in config.json per agent", true - case "channels": + if commandReply != "" { + return commandReply, true + } + return "", true + default: // OutcomePassthrough — let the message fall through to LLM + return "", false + } +} + +func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime { + rt := &commands.Runtime{ + Config: al.cfg, + ListAgentIDs: al.registry.ListAgentIDs, + ListDefinitions: al.cmdRegistry.Definitions, + GetEnabledChannels: func() []string { if al.channelManager == nil { - return "Channel manager not initialized", true + return nil } - channels := al.channelManager.GetEnabledChannels() - if len(channels) == 0 { - return "No channels enabled", true - } - return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true - case "agents": - agentIDs := al.registry.ListAgentIDs() - return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true - default: - return fmt.Sprintf("Unknown list target: %s", args[0]), true - } - - case "/switch": - if len(args) < 3 || args[1] != "to" { - return "Usage: /switch [model|channel] to ", true - } - target := args[0] - value := args[2] - - switch target { - case "model": - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent == nil { - return "No default agent configured", true - } - oldModel := defaultAgent.Model - defaultAgent.Model = value - return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true - case "channel": + return al.channelManager.GetEnabledChannels() + }, + SwitchChannel: func(value string) error { if al.channelManager == nil { - return "Channel manager not initialized", true + return fmt.Errorf("channel manager not initialized") } if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Sprintf("Channel '%s' not found or not enabled", value), true + return fmt.Errorf("channel '%s' not found or not enabled", value) } - return fmt.Sprintf("Switched target channel to %s", value), true - default: - return fmt.Sprintf("Unknown switch target: %s", target), true + return nil + }, + } + if agent != nil { + rt.GetModelInfo = func() (string, string) { + return agent.Model, al.cfg.Agents.Defaults.Provider + } + rt.SwitchModel = func(value string) (string, error) { + oldModel := agent.Model + agent.Model = value + return oldModel, nil } } + return rt +} - return "", false +func mapCommandError(result commands.ExecuteResult) string { + if result.Command == "" { + return fmt.Sprintf("Failed to execute command: %v", result.Err) + } + return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) } // extractPeer extracts the routing peer from the inbound message's structured Peer field. @@ -1610,10 +1636,17 @@ func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} } +func inboundMetadata(msg bus.InboundMessage, key string) string { + if msg.Metadata == nil { + return "" + } + return msg.Metadata[key] +} + // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { - parentKind := msg.Metadata["parent_peer_kind"] - parentID := msg.Metadata["parent_peer_id"] + parentKind := inboundMetadata(msg, metadataKeyParentPeerKind) + parentID := inboundMetadata(msg, metadataKeyParentPeerID) if parentKind == "" || parentID == "" { return nil } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index aa7d59b5a..2e456fa60 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -318,6 +319,29 @@ func (m *simpleMockProvider) GetDefaultModel() string { return "mock-model" } +type countingMockProvider struct { + response string + calls int +} + +func (m *countingMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *countingMockProvider) GetDefaultModel() string { + return "counting-mock-model" +} + // mockCustomTool is a simple mock tool for registration testing type mockCustomTool struct{} @@ -359,6 +383,198 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms const responseTimeout = 3 * time.Second +func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-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: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "ok"} + al := NewAgentLoop(cfg, msgBus, provider) + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + } + + route := al.registry.ResolveRoute(routing.RouteInput{ + Channel: msg.Channel, + Peer: extractPeer(msg), + }) + sessionKey := route.SessionKey + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + helper := testHelper{al: al} + _ = helper.executeAndGetResponse(t, context.Background(), msg) + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 2 { + t.Fatalf("expected session history len=2, got %d", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Fatalf("unexpected first message in session: %+v", history[0]) + } +} + +func TestProcessMessage_CommandOutcomes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-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: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + DMScope: "per-channel-peer", + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseMsg := bus.InboundMessage{ + Channel: "whatsapp", + SenderID: "user1", + ChatID: "chat1", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: baseMsg.Channel, + SenderID: baseMsg.SenderID, + ChatID: baseMsg.ChatID, + Content: "/show channel", + Peer: baseMsg.Peer, + }) + if showResp != "Current Channel: whatsapp" { + t.Fatalf("unexpected /show reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls) + } + + fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: baseMsg.Channel, + SenderID: baseMsg.SenderID, + ChatID: baseMsg.ChatID, + Content: "/foo", + Peer: baseMsg.Peer, + }) + if fooResp != "LLM reply" { + t.Fatalf("unexpected /foo reply: %q", fooResp) + } + if provider.calls != 1 { + t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) + } + + newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: baseMsg.Channel, + SenderID: baseMsg.SenderID, + ChatID: baseMsg.ChatID, + Content: "/new", + Peer: baseMsg.Peer, + }) + if newResp != "LLM reply" { + t.Fatalf("unexpected /new reply: %q", newResp) + } + if provider.calls != 2 { + t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-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, + Provider: "openai", + Model: "before-switch", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to after-switch", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") { + t.Fatalf("unexpected /switch reply: %q", switchResp) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/show model", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") { + t.Fatalf("unexpected /show model reply after switch: %q", showResp) + } + + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls) + } +} + // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 74caeeac5..b3a493761 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -1,6 +1,10 @@ package channels -import "context" +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/commands" +) // TypingCapable — channels that can show a typing/thinking indicator. // StartTyping begins the indicator and returns a stop function. @@ -39,3 +43,10 @@ type PlaceholderRecorder interface { RecordTypingStop(channel, chatID string, stop func()) RecordReactionUndo(channel, chatID string, undo func()) } + +// CommandRegistrarCapable is implemented by channels that can register +// command menus with their upstream platform (e.g. Telegram BotCommand). +// Channels that do not support platform-level command menus can ignore it. +type CommandRegistrarCapable interface { + RegisterCommands(ctx context.Context, defs []commands.Definition) error +} diff --git a/pkg/channels/interfaces_command_test.go b/pkg/channels/interfaces_command_test.go new file mode 100644 index 000000000..de5502644 --- /dev/null +++ b/pkg/channels/interfaces_command_test.go @@ -0,0 +1,16 @@ +package channels + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/commands" +) + +type mockRegistrar struct{} + +func (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil } + +func TestCommandRegistrarCapable_Compiles(t *testing.T) { + var _ CommandRegistrarCapable = mockRegistrar{} +} diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go new file mode 100644 index 000000000..d3152ec3d --- /dev/null +++ b/pkg/channels/telegram/command_registration.go @@ -0,0 +1,116 @@ +package telegram + +import ( + "context" + "math/rand" + "slices" + "time" + + "github.com/mymmrac/telego" + + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/logger" +) + +var commandRegistrationBackoff = []time.Duration{ + 5 * time.Second, + 15 * time.Second, + 60 * time.Second, + 5 * time.Minute, + 10 * time.Minute, +} + +func commandRegistrationDelay(attempt int) time.Duration { + if len(commandRegistrationBackoff) == 0 { + return 0 + } + base := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)] + // Full jitter in [0.5, 1.0) to avoid synchronized retries across instances. + return time.Duration(float64(base) * (0.5 + rand.Float64()*0.5)) +} + +// RegisterCommands registers bot commands on Telegram platform. +func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error { + botCommands := make([]telego.BotCommand, 0, len(defs)) + for _, def := range defs { + if def.Name == "" || def.Description == "" { + continue + } + botCommands = append(botCommands, telego.BotCommand{ + Command: def.Name, + Description: def.Description, + }) + } + + current, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{}) + if err != nil { + // If we can't read current commands, fall through to set them. + logger.WarnCF("telegram", "Failed to get current commands, will set unconditionally", + map[string]any{"error": err.Error()}) + } else if slices.Equal(current, botCommands) { + logger.DebugCF("telegram", "Bot commands are up to date", nil) + return nil + } + + return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{ + Commands: botCommands, + }) +} + +func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) { + if len(defs) == 0 { + return + } + + register := c.registerFunc + if register == nil { + register = c.RegisterCommands + } + + regCtx, cancel := context.WithCancel(ctx) + c.commandRegCancel = cancel + + // Registration runs asynchronously so Telegram message intake is never blocked + // by temporary upstream API failures. Retry stops on success or channel shutdown. + go func() { + attempt := 0 + timer := time.NewTimer(0) + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + defer timer.Stop() + for { + err := register(regCtx, defs) + if err == nil { + logger.InfoCF("telegram", "Telegram commands registered", map[string]any{ + "count": len(defs), + }) + return + } + + delay := commandRegistrationDelay(attempt) + logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ + "error": err.Error(), + "retry_after": delay.String(), + }) + attempt++ + + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(delay) + + select { + case <-regCtx.Done(): + return + case <-timer.C: + } + } + }() +} diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go new file mode 100644 index 000000000..26f891b2e --- /dev/null +++ b/pkg/channels/telegram/command_registration_test.go @@ -0,0 +1,96 @@ +package telegram + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/commands" +) + +func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { + ch := &TelegramChannel{} + started := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ch.registerFunc = func(context.Context, []commands.Definition) error { + started <- struct{}{} + return errors.New("temporary failure") + } + + ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help"}}) + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("registration did not start asynchronously") + } +} + +func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { + ch := &TelegramChannel{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + origBackoff := commandRegistrationBackoff + commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} + defer func() { commandRegistrationBackoff = origBackoff }() + + var attempts atomic.Int32 + ch.registerFunc = func(context.Context, []commands.Definition) error { + n := attempts.Add(1) + if n < 3 { + return errors.New("temporary failure") + } + return nil + } + + ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}}) + + deadline := time.Now().Add(250 * time.Millisecond) + for time.Now().Before(deadline) { + if attempts.Load() >= 3 { + break + } + time.Sleep(5 * time.Millisecond) + } + if attempts.Load() < 3 { + t.Fatalf("expected at least 3 attempts, got %d", attempts.Load()) + } + + stable := attempts.Load() + time.Sleep(30 * time.Millisecond) + if attempts.Load() != stable { + t.Fatalf("expected retries to stop after success, got %d -> %d", stable, attempts.Load()) + } +} + +func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { + ch := &TelegramChannel{} + ctx, cancel := context.WithCancel(context.Background()) + + origBackoff := commandRegistrationBackoff + commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} + defer func() { commandRegistrationBackoff = origBackoff }() + defer cancel() + + var attempts atomic.Int32 + ch.registerFunc = func(context.Context, []commands.Definition) error { + attempts.Add(1) + return errors.New("always fail") + } + + ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}}) + + time.Sleep(20 * time.Millisecond) + cancel() + time.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle + stable := attempts.Load() + time.Sleep(30 * time.Millisecond) + if attempts.Load() != stable { + t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load()) + } +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f328f32b8..a2035853c 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -7,7 +7,6 @@ import ( "net/url" "os" "regexp" - "slices" "strconv" "strings" "time" @@ -18,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" @@ -40,13 +40,15 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - commands TelegramCommander - config *config.Config - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc + bot *telego.Bot + bh *th.BotHandler + config *config.Config + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + + registerFunc func(context.Context, []commands.Definition) error + commandRegCancel context.CancelFunc } func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { @@ -93,7 +95,6 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann return &TelegramChannel{ BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), bot: bot, config: cfg, chatIDs: make(map[string]int64), @@ -105,12 +106,6 @@ 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, }) @@ -126,21 +121,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error { } c.bh = 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()) @@ -150,6 +130,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error { "username": c.bot.Username(), }) + c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions()) + go func() { if err = bh.Start(); err != nil { logger.ErrorCF("telegram", "Bot handler failed", map[string]any{ @@ -174,50 +156,8 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } - - 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") + if c.commandRegCancel != nil { + c.commandRegCancel() } return nil @@ -721,34 +661,34 @@ func escapeHTML(text string) string { // isBotMentioned checks if the bot is mentioned in the message via entities. func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { - botUsername := c.bot.Username() - if botUsername == "" { + text, entities := telegramEntityTextAndList(message) + if text == "" || len(entities) == 0 { return false } - entities := message.Entities - if entities == nil { - entities = message.CaptionEntities + botUsername := "" + if c.bot != nil { + botUsername = c.bot.Username() } + runes := []rune(text) for _, entity := range entities { - if entity.Type == "mention" { - // Extract the mention text from the message - text := message.Text - if text == "" { - text = message.Caption - } - runes := []rune(text) - end := entity.Offset + entity.Length - if end <= len(runes) { - mention := string(runes[entity.Offset:end]) - if strings.EqualFold(mention, "@"+botUsername) { - return true - } - } + entityText, ok := telegramEntityText(runes, entity) + if !ok { + continue } - if entity.Type == "text_mention" && entity.User != nil { - if entity.User.Username == botUsername { + + switch entity.Type { + case telego.EntityTypeMention: + if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) { + return true + } + case telego.EntityTypeTextMention: + if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) { + return true + } + case telego.EntityTypeBotCommand: + if isBotCommandEntityForThisBot(entityText, botUsername) { return true } } @@ -756,6 +696,46 @@ func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { return false } +func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) { + if message.Text != "" { + return message.Text, message.Entities + } + return message.Caption, message.CaptionEntities +} + +func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) { + if entity.Offset < 0 || entity.Length <= 0 { + return "", false + } + end := entity.Offset + entity.Length + if entity.Offset >= len(runes) || end > len(runes) { + return "", false + } + return string(runes[entity.Offset:end]), true +} + +func isBotCommandEntityForThisBot(entityText, botUsername string) bool { + if !strings.HasPrefix(entityText, "/") { + return false + } + command := strings.TrimPrefix(entityText, "/") + if command == "" { + return false + } + + at := strings.IndexRune(command, '@') + if at == -1 { + // A bare /command delivered to this bot is intended for this bot. + return true + } + + mentionUsername := command[at+1:] + if mentionUsername == "" || botUsername == "" { + return false + } + return strings.EqualFold(mentionUsername, botUsername) +} + // stripBotMention removes the @bot mention from the content. func (c *TelegramChannel) stripBotMention(content string) string { botUsername := c.bot.Username() diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go deleted file mode 100644 index 496fc5e4f..000000000 --- a/pkg/channels/telegram/telegram_commands.go +++ /dev/null @@ -1,156 +0,0 @@ -package telegram - -import ( - "context" - "fmt" - "strings" - - "github.com/mymmrac/telego" - - "github.com/sipeed/picoclaw/pkg/config" -) - -type TelegramCommander interface { - Help(ctx context.Context, message telego.Message) error - Start(ctx context.Context, message telego.Message) error - Show(ctx context.Context, message telego.Message) error - List(ctx context.Context, message telego.Message) error -} - -type cmd struct { - bot *telego.Bot - config *config.Config -} - -func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { - return &cmd{ - bot: bot, - config: cfg, - } -} - -func commandArgs(text string) string { - parts := strings.SplitN(text, " ", 2) - if len(parts) < 2 { - return "" - } - return strings.TrimSpace(parts[1]) -} - -func (c *cmd) Help(ctx context.Context, message telego.Message) error { - msg := `/start - Start the bot -/help - Show this help message -/show [model|channel] - Show current configuration -/list [models|channels] - List available options - ` - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: msg, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) Start(ctx context.Context, message telego.Message) error { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Hello! I am PicoClaw 🦞", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) Show(ctx context.Context, message telego.Message) error { - args := commandArgs(message.Text) - if args == "" { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /show [model|channel]", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err - } - - var response string - switch args { - case "model": - response = fmt.Sprintf("Current Model: %s (Provider: %s)", - c.config.Agents.Defaults.GetModelName(), - c.config.Agents.Defaults.Provider) - case "channel": - response = "Current Channel: telegram" - default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) - } - - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: response, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) List(ctx context.Context, message telego.Message) error { - args := commandArgs(message.Text) - if args == "" { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /list [models|channels]", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err - } - - var response string - switch args { - case "models": - provider := c.config.Agents.Defaults.Provider - if provider == "" { - provider = "configured default" - } - response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json", - c.config.Agents.Defaults.GetModelName(), provider) - - case "channels": - var enabled []string - if c.config.Channels.Telegram.Enabled { - enabled = append(enabled, "telegram") - } - if c.config.Channels.WhatsApp.Enabled { - enabled = append(enabled, "whatsapp") - } - if c.config.Channels.Feishu.Enabled { - enabled = append(enabled, "feishu") - } - if c.config.Channels.Discord.Enabled { - enabled = append(enabled, "discord") - } - if c.config.Channels.Slack.Enabled { - enabled = append(enabled, "slack") - } - response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) - - default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) - } - - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: response, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go new file mode 100644 index 000000000..1ea4a4824 --- /dev/null +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -0,0 +1,52 @@ +package telegram + +import ( + "context" + "testing" + "time" + + "github.com/mymmrac/telego" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "/new", + MessageID: 9, + Chat: telego.Chat{ + ID: 123, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + if err := ch.handleMessage(context.Background(), msg); err != nil { + t.Fatalf("handleMessage error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Channel != "telegram" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.Content != "/new" { + t.Fatalf("content=%q", inbound.Content) + } +} diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go new file mode 100644 index 000000000..0d5b985fe --- /dev/null +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -0,0 +1,147 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/mymmrac/telego" + ta "github.com/mymmrac/telego/telegoapi" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +type getMeCaller struct { + username string +} + +func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) { + if strings.HasSuffix(url, "/getMe") { + result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username) + return &ta.Response{Ok: true, Result: []byte(result)}, nil + } + return &ta.Response{Ok: true, Result: []byte("true")}, nil +} + +func newTestTelegramBot(t *testing.T, username string) *telego.Bot { + t.Helper() + + token := "123456:" + strings.Repeat("a", 35) + bot, err := telego.NewBot(token, + telego.WithAPICaller(getMeCaller{username: username}), + telego.WithDiscardLogger(), + ) + if err != nil { + t.Fatalf("NewBot error: %v", err) + } + return bot +} + +func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) { + t.Helper() + + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil, + channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}), + ), + bot: newTestTelegramBot(t, botUsername), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + return ch, messageBus +} + +func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { + tests := []struct { + name string + text string + wantForwarded bool + wantContent string + }{ + { + name: "command with bot username", + text: "/new@testbot", + wantForwarded: true, + wantContent: "/new", + }, + { + name: "bare command", + text: "/new", + wantForwarded: true, + wantContent: "/new", + }, + { + name: "command for another bot", + text: "/new@otherbot", + wantForwarded: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ch, messageBus := newGroupMentionOnlyChannel(t, "testbot") + + msg := &telego.Message{ + Text: tc.text, + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeBotCommand, + Offset: 0, + Length: len([]rune(tc.text)), + }}, + MessageID: 42, + Chat: telego.Chat{ + ID: 123, + Type: "group", + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + + if err := ch.handleMessage(context.Background(), msg); err != nil { + t.Fatalf("handleMessage error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if tc.wantForwarded { + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != tc.wantContent { + t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) + } + return + } + + if ok { + t.Fatalf("expected message to be filtered, got content=%q", inbound.Content) + } + }) + } +} + +func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) { + ch, _ := newGroupMentionOnlyChannel(t, "testbot") + + msg := &telego.Message{ + Text: "@testbot hello", + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeMention, + Offset: 0, + Length: len("@testbot"), + }}, + } + + if !ch.isBotMentioned(msg) { + t.Fatal("expected mention entity to be treated as bot mention") + } +} diff --git a/pkg/channels/whatsapp/whatsapp_command_test.go b/pkg/channels/whatsapp/whatsapp_command_test.go new file mode 100644 index 000000000..ee8aa4a52 --- /dev/null +++ b/pkg/channels/whatsapp/whatsapp_command_test.go @@ -0,0 +1,41 @@ +package whatsapp + +import ( + "context" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &WhatsAppChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil), + ctx: context.Background(), + } + + ch.handleIncomingMessage(map[string]any{ + "type": "message", + "id": "mid1", + "from": "user1", + "chat": "chat1", + "content": "/help", + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Channel != "whatsapp" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.Content != "/help" { + t.Fatalf("content=%q", inbound.Content) + } +} diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go new file mode 100644 index 000000000..cc2dcb619 --- /dev/null +++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go @@ -0,0 +1,56 @@ +//go:build whatsapp_native + +package whatsapp + +import ( + "context" + "testing" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil), + runCtx: context.Background(), + } + + evt := &events.Message{ + Info: types.MessageInfo{ + MessageSource: types.MessageSource{ + Sender: types.NewJID("1001", types.DefaultUserServer), + Chat: types.NewJID("1001", types.DefaultUserServer), + }, + ID: "mid1", + PushName: "Alice", + }, + Message: &waE2E.Message{ + Conversation: proto.String("/new"), + }, + } + + ch.handleIncoming(evt) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Channel != "whatsapp_native" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.Content != "/new" { + t.Fatalf("content=%q", inbound.Content) + } +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go new file mode 100644 index 000000000..a36dd3eba --- /dev/null +++ b/pkg/commands/builtin.go @@ -0,0 +1,16 @@ +package commands + +// BuiltinDefinitions returns all built-in command definitions. +// Each command group is defined in its own cmd_*.go file. +// Definitions are stateless — runtime dependencies are provided +// via the Runtime parameter passed to handlers at execution time. +func BuiltinDefinitions() []Definition { + return []Definition{ + startCommand(), + helpCommand(), + showCommand(), + listCommand(), + switchCommand(), + checkCommand(), + } +} diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go new file mode 100644 index 000000000..66a84825e --- /dev/null +++ b/pkg/commands/builtin_test.go @@ -0,0 +1,145 @@ +package commands + +import ( + "context" + "strings" + "testing" +) + +func findDefinitionByName(t *testing.T, defs []Definition, name string) Definition { + t.Helper() + for _, def := range defs { + if def.Name == name { + return def + } + } + t.Fatalf("missing /%s definition", name) + return Definition{} +} + +func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { + defs := BuiltinDefinitions() + helpDef := findDefinitionByName(t, defs, "help") + if helpDef.Handler == nil { + t.Fatalf("/help handler should not be nil") + } + + var reply string + err := helpDef.Handler(context.Background(), Request{ + Text: "/help", + Reply: func(text string) error { + reply = text + return nil + }, + }, nil) + if err != nil { + t.Fatalf("/help handler error: %v", err) + } + // Now uses auto-generated EffectiveUsage which includes agents + if !strings.Contains(reply, "/show [model|channel|agents]") { + t.Fatalf("/help reply missing /show usage, got %q", reply) + } + if !strings.Contains(reply, "/list [models|channels|agents]") { + t.Fatalf("/help reply missing /list usage, got %q", reply) + } +} + +func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), nil) + + cases := []string{"telegram", "whatsapp"} + for _, channel := range cases { + var reply string + res := ex.Execute(context.Background(), Request{ + Channel: channel, + Text: "/show channel", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/show channel on %s: outcome=%v, want=%v", channel, res.Outcome, OutcomeHandled) + } + want := "Current Channel: " + channel + if reply != want { + t.Fatalf("/show channel reply=%q, want=%q", reply, want) + } + } +} + +func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) { + rt := &Runtime{ + GetEnabledChannels: func() []string { + return []string{"telegram", "slack"} + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list channels", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list channels: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "telegram") || !strings.Contains(reply, "slack") { + t.Fatalf("/list channels reply=%q, want telegram and slack", reply) + } +} + +func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) { + rt := &Runtime{ + ListAgentIDs: func() []string { + return []string{"default", "coder"} + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show agents", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/show agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") { + t.Fatalf("/show agents reply=%q, want agent IDs", reply) + } +} + +func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) { + rt := &Runtime{ + ListAgentIDs: func() []string { + return []string{"default", "coder"} + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list agents", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") { + t.Fatalf("/list agents reply=%q, want agent IDs", reply) + } +} diff --git a/pkg/commands/cmd_check.go b/pkg/commands/cmd_check.go new file mode 100644 index 000000000..f0193dc4f --- /dev/null +++ b/pkg/commands/cmd_check.go @@ -0,0 +1,33 @@ +package commands + +import ( + "context" + "fmt" +) + +func checkCommand() Definition { + return Definition{ + Name: "check", + Description: "Check channel availability", + SubCommands: []SubCommand{ + { + Name: "channel", + Description: "Check if a channel is available", + ArgsUsage: "", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.SwitchChannel == nil { + return req.Reply(unavailableMsg) + } + value := nthToken(req.Text, 2) + if value == "" { + return req.Reply("Usage: /check channel ") + } + if err := rt.SwitchChannel(value); err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value)) + }, + }, + }, + } +} diff --git a/pkg/commands/cmd_help.go b/pkg/commands/cmd_help.go new file mode 100644 index 000000000..94f7f0101 --- /dev/null +++ b/pkg/commands/cmd_help.go @@ -0,0 +1,44 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func helpCommand() Definition { + return Definition{ + Name: "help", + Description: "Show this help message", + Usage: "/help", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + var defs []Definition + if rt != nil && rt.ListDefinitions != nil { + defs = rt.ListDefinitions() + } else { + defs = BuiltinDefinitions() + } + return req.Reply(formatHelpMessage(defs)) + }, + } +} + +func formatHelpMessage(defs []Definition) string { + if len(defs) == 0 { + return "No commands available." + } + + lines := make([]string, 0, len(defs)) + for _, def := range defs { + usage := def.EffectiveUsage() + if usage == "" { + usage = "/" + def.Name + } + desc := def.Description + if desc == "" { + desc = "No description" + } + lines = append(lines, fmt.Sprintf("%s - %s", usage, desc)) + } + return strings.Join(lines, "\n") +} diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go new file mode 100644 index 000000000..bf47b6e9c --- /dev/null +++ b/pkg/commands/cmd_list.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func listCommand() Definition { + return Definition{ + Name: "list", + Description: "List available options", + SubCommands: []SubCommand{ + { + Name: "models", + Description: "Configured models", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetModelInfo == nil { + return req.Reply(unavailableMsg) + } + name, provider := rt.GetModelInfo() + if provider == "" { + provider = "configured default" + } + return req.Reply(fmt.Sprintf( + "Configured Model: %s\nProvider: %s\n\nTo change models, update config.json", + name, provider, + )) + }, + }, + { + Name: "channels", + Description: "Enabled channels", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetEnabledChannels == nil { + return req.Reply(unavailableMsg) + } + enabled := rt.GetEnabledChannels() + if len(enabled) == 0 { + return req.Reply("No channels enabled") + } + return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))) + }, + }, + { + Name: "agents", + Description: "Registered agents", + Handler: agentsHandler(), + }, + }, + } +} diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go new file mode 100644 index 000000000..c655e6880 --- /dev/null +++ b/pkg/commands/cmd_show.go @@ -0,0 +1,38 @@ +package commands + +import ( + "context" + "fmt" +) + +func showCommand() Definition { + return Definition{ + Name: "show", + Description: "Show current configuration", + SubCommands: []SubCommand{ + { + Name: "model", + Description: "Current model and provider", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetModelInfo == nil { + return req.Reply(unavailableMsg) + } + name, provider := rt.GetModelInfo() + return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider)) + }, + }, + { + Name: "channel", + Description: "Current channel", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel)) + }, + }, + { + Name: "agents", + Description: "Registered agents", + Handler: agentsHandler(), + }, + }, + } +} diff --git a/pkg/commands/cmd_start.go b/pkg/commands/cmd_start.go new file mode 100644 index 000000000..8b500aa10 --- /dev/null +++ b/pkg/commands/cmd_start.go @@ -0,0 +1,14 @@ +package commands + +import "context" + +func startCommand() Definition { + return Definition{ + Name: "start", + Description: "Start the bot", + Usage: "/start", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("Hello! I am PicoClaw 🦞") + }, + } +} diff --git a/pkg/commands/cmd_switch.go b/pkg/commands/cmd_switch.go new file mode 100644 index 000000000..fb8fc109e --- /dev/null +++ b/pkg/commands/cmd_switch.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +func switchCommand() Definition { + return Definition{ + Name: "switch", + Description: "Switch model", + SubCommands: []SubCommand{ + { + Name: "model", + Description: "Switch to a different model", + ArgsUsage: "to ", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.SwitchModel == nil { + return req.Reply(unavailableMsg) + } + // Parse: /switch model to + value := nthToken(req.Text, 3) // tokens: [/switch, model, to, ] + if nthToken(req.Text, 2) != "to" || value == "" { + return req.Reply("Usage: /switch model to ") + } + oldModel, err := rt.SwitchModel(value) + if err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value)) + }, + }, + { + Name: "channel", + Description: "Moved to /check channel", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("This command has moved. Please use: /check channel ") + }, + }, + }, + } +} diff --git a/pkg/commands/cmd_switch_test.go b/pkg/commands/cmd_switch_test.go new file mode 100644 index 000000000..59ed305bb --- /dev/null +++ b/pkg/commands/cmd_switch_test.go @@ -0,0 +1,279 @@ +package commands + +import ( + "context" + "fmt" + "testing" +) + +func TestSwitchModel_Success(t *testing.T) { + rt := &Runtime{ + SwitchModel: func(value string) (string, error) { + return "old-model", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + want := "Switched model from old-model to gpt-4" + if reply != want { + t.Fatalf("reply=%q, want=%q", reply, want) + } +} + +func TestSwitchModel_MissingToKeyword(t *testing.T) { + rt := &Runtime{ + SwitchModel: func(value string) (string, error) { + return "old", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /switch model to " { + t.Fatalf("reply=%q, want usage message", reply) + } +} + +func TestSwitchModel_MissingValue(t *testing.T) { + rt := &Runtime{ + SwitchModel: func(value string) (string, error) { + return "old", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /switch model to " { + t.Fatalf("reply=%q, want usage message", reply) + } +} + +func TestSwitchModel_Error(t *testing.T) { + rt := &Runtime{ + SwitchModel: func(value string) (string, error) { + return "", fmt.Errorf("model not found") + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to bad-model", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "model not found" { + t.Fatalf("reply=%q, want error message", reply) + } +} + +func TestSwitchModel_NilDep(t *testing.T) { + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Command unavailable in current context." { + t.Fatalf("reply=%q, want unavailable message", reply) + } +} + +func TestSwitchChannel_Redirect(t *testing.T) { + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch channel to telegram", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + want := "This command has moved. Please use: /check channel " + if reply != want { + t.Fatalf("reply=%q, want=%q", reply, want) + } +} + +func TestCheckChannel_Success(t *testing.T) { + rt := &Runtime{ + SwitchChannel: func(value string) error { + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/check channel telegram", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + want := "Channel 'telegram' is available and enabled" + if reply != want { + t.Fatalf("reply=%q, want=%q", reply, want) + } +} + +func TestCheckChannel_Error(t *testing.T) { + rt := &Runtime{ + SwitchChannel: func(value string) error { + return fmt.Errorf("channel '%s' not found", value) + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/check channel unknown", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "channel 'unknown' not found" { + t.Fatalf("reply=%q, want error message", reply) + } +} + +func TestCheckChannel_NilDep(t *testing.T) { + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/check channel telegram", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Command unavailable in current context." { + t.Fatalf("reply=%q, want unavailable message", reply) + } +} + +func TestCheckChannel_MissingValue(t *testing.T) { + rt := &Runtime{ + SwitchChannel: func(value string) error { + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/check channel", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /check channel " { + t.Fatalf("reply=%q, want usage message", reply) + } +} + +func TestSwitch_BangPrefix(t *testing.T) { + rt := &Runtime{ + SwitchModel: func(value string) (string, error) { + return "old", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "!switch model to gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("! prefix: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Switched model from old to gpt-4" { + t.Fatalf("! prefix: reply=%q, want success message", reply) + } +} + +func TestSwitch_NoSubCommand(t *testing.T) { + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{}) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + // Should get usage message from executor's sub-command routing + if reply == "" { + t.Fatal("expected usage reply for bare /switch") + } +} diff --git a/pkg/commands/definition.go b/pkg/commands/definition.go new file mode 100644 index 000000000..7309df317 --- /dev/null +++ b/pkg/commands/definition.go @@ -0,0 +1,48 @@ +package commands + +import ( + "fmt" + "strings" +) + +// SubCommand defines a single sub-command within a parent command. +type SubCommand struct { + Name string + Description string + ArgsUsage string // optional, e.g. "" + Handler Handler +} + +// Definition is the single-source metadata and behavior contract for a slash command. +// +// Design notes (phase 1): +// - Every channel reads command shape from this type instead of keeping local copies. +// - Visibility is global: all definitions are considered available to all channels. +// - Platform menu registration (for example Telegram BotCommand) also derives from this +// same definition so UI labels and runtime behavior stay aligned. +type Definition struct { + Name string + Description string + Usage string // for simple commands; ignored when SubCommands is set + Aliases []string + SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers + Handler Handler // for simple commands without sub-commands +} + +// EffectiveUsage returns the usage string. When SubCommands are present, +// it is auto-generated from sub-command names so metadata and behavior +// cannot drift. +func (d Definition) EffectiveUsage() string { + if len(d.SubCommands) == 0 { + return d.Usage + } + names := make([]string, 0, len(d.SubCommands)) + for _, sc := range d.SubCommands { + name := sc.Name + if sc.ArgsUsage != "" { + name += " " + sc.ArgsUsage + } + names = append(names, name) + } + return fmt.Sprintf("/%s [%s]", d.Name, strings.Join(names, "|")) +} diff --git a/pkg/commands/definition_test.go b/pkg/commands/definition_test.go new file mode 100644 index 000000000..27ad4a0a2 --- /dev/null +++ b/pkg/commands/definition_test.go @@ -0,0 +1,41 @@ +package commands + +import ( + "testing" +) + +func TestDefinition_EffectiveUsage_NoSubCommands(t *testing.T) { + d := Definition{Name: "start", Usage: "/start"} + if got := d.EffectiveUsage(); got != "/start" { + t.Fatalf("EffectiveUsage()=%q, want %q", got, "/start") + } +} + +func TestDefinition_EffectiveUsage_WithSubCommands(t *testing.T) { + d := Definition{ + Name: "show", + SubCommands: []SubCommand{ + {Name: "model"}, + {Name: "channel"}, + {Name: "agents"}, + }, + } + want := "/show [model|channel|agents]" + if got := d.EffectiveUsage(); got != want { + t.Fatalf("EffectiveUsage()=%q, want %q", got, want) + } +} + +func TestDefinition_EffectiveUsage_WithArgsUsage(t *testing.T) { + d := Definition{ + Name: "session", + SubCommands: []SubCommand{ + {Name: "list"}, + {Name: "resume", ArgsUsage: ""}, + }, + } + want := "/session [list|resume ]" + if got := d.EffectiveUsage(); got != want { + t.Fatalf("EffectiveUsage()=%q, want %q", got, want) + } +} diff --git a/pkg/commands/executor.go b/pkg/commands/executor.go new file mode 100644 index 000000000..78a50e6c2 --- /dev/null +++ b/pkg/commands/executor.go @@ -0,0 +1,89 @@ +package commands + +import ( + "context" + "fmt" +) + +type Outcome int + +const ( + // OutcomePassthrough means this input should continue through normal agent flow. + OutcomePassthrough Outcome = iota + // OutcomeHandled means a command handler executed (with or without handler error). + OutcomeHandled +) + +type ExecuteResult struct { + Outcome Outcome + Command string + Err error +} + +type Executor struct { + reg *Registry + rt *Runtime +} + +func NewExecutor(reg *Registry, rt *Runtime) *Executor { + return &Executor{reg: reg, rt: rt} +} + +// Execute implements a two-state command decision: +// 1) handled: execute command immediately; +// 2) passthrough: not a command or intentionally deferred to agent logic. +func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult { + cmdName, ok := parseCommandName(req.Text) + if !ok { + return ExecuteResult{Outcome: OutcomePassthrough} + } + + if e == nil || e.reg == nil { + return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName} + } + + def, found := e.reg.Lookup(cmdName) + if !found { + return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName} + } + + return e.executeDefinition(ctx, req, def) +} + +func (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult { + // Ensure Reply is always non-nil so handlers don't need to check. + if req.Reply == nil { + req.Reply = func(string) error { return nil } + } + + // Simple command — no sub-commands + if len(def.SubCommands) == 0 { + if def.Handler == nil { + return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name} + } + err := def.Handler(ctx, req, e.rt) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} + } + + // Sub-command routing + subName := nthToken(req.Text, 1) + if subName == "" { + err := req.Reply("Usage: " + def.EffectiveUsage()) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} + } + + normalized := normalizeCommandName(subName) + for _, sc := range def.SubCommands { + if normalizeCommandName(sc.Name) == normalized { + if sc.Handler == nil { + return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name} + } + err := sc.Handler(ctx, req, e.rt) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} + } + } + + // Unknown sub-command + err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage())) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} +} diff --git a/pkg/commands/executor_test.go b/pkg/commands/executor_test.go new file mode 100644 index 000000000..09350f1b6 --- /dev/null +++ b/pkg/commands/executor_test.go @@ -0,0 +1,260 @@ +package commands + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) { + defs := []Definition{{Name: "show"}} + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"}) + if res.Outcome != OutcomePassthrough { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } +} + +func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) { + defs := []Definition{{Name: "show"}} + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"}) + if res.Outcome != OutcomePassthrough { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } +} + +func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) { + called := false + defs := []Definition{ + { + Name: "help", + Handler: func(context.Context, Request, *Runtime) error { + called = true + return nil + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"}) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !called { + t.Fatalf("expected handler to be called") + } +} + +func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) { + defs := []Definition{ + { + Name: "show", + Aliases: []string{"display"}, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"}) + if res.Outcome != OutcomePassthrough { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } + if res.Command != "show" { + t.Fatalf("command=%q, want=%q", res.Command, "show") + } +} + +func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) { + called := false + defs := []Definition{ + { + Name: "clear", + Aliases: []string{"reset"}, + Handler: func(context.Context, Request, *Runtime) error { + called = true + return nil + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"}) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if res.Command != "clear" { + t.Fatalf("command=%q, want=%q", res.Command, "clear") + } + if !called { + t.Fatalf("expected handler to be called") + } +} + +func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) { + defs := []Definition{ + {Name: "placeholder"}, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"}) + if res.Outcome != OutcomePassthrough { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } + if res.Command != "placeholder" { + t.Fatalf("command=%q, want=%q", res.Command, "placeholder") + } +} + +func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) { + // With Lookup-based dispatch, the first registered definition for a name wins. + // A definition with nil Handler and no SubCommands returns Passthrough. + defs := []Definition{ + {Name: "placeholder"}, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"}) + if res.Outcome != OutcomePassthrough { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } + if res.Command != "placeholder" { + t.Fatalf("command=%q, want=%q", res.Command, "placeholder") + } +} + +func TestExecutor_HandlerErrorIsPropagated(t *testing.T) { + wantErr := errors.New("handler failed") + defs := []Definition{ + { + Name: "help", + Handler: func(context.Context, Request, *Runtime) error { + return wantErr + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"}) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !errors.Is(res.Err, wantErr) { + t.Fatalf("err=%v, want=%v", res.Err, wantErr) + } +} + +func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) { + called := false + defs := []Definition{ + { + Name: "help", + Handler: func(context.Context, Request, *Runtime) error { + called = true + return nil + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"}) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !called { + t.Fatalf("expected handler to be called") + } +} + +func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) { + modelCalled := false + defs := []Definition{ + { + Name: "show", + SubCommands: []SubCommand{ + {Name: "model", Handler: func(_ context.Context, _ Request, _ *Runtime) error { + modelCalled = true + return nil + }}, + {Name: "channel"}, + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Text: "/show model"}) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !modelCalled { + t.Fatal("model sub-command handler was not called") + } +} + +func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) { + defs := []Definition{ + { + Name: "show", + SubCommands: []SubCommand{ + {Name: "model"}, + {Name: "channel"}, + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show", + Reply: func(text string) error { reply = text; return nil }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /show [model|channel]" { + t.Fatalf("reply=%q, want usage message", reply) + } +} + +func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) { + defs := []Definition{ + { + Name: "show", + SubCommands: []SubCommand{ + {Name: "model"}, + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show foobar", + Reply: func(text string) error { reply = text; return nil }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "foobar") { + t.Fatalf("reply=%q, should mention unknown sub-command", reply) + } +} + +func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) { + defs := []Definition{ + { + Name: "show", + SubCommands: []SubCommand{ + {Name: "model"}, // nil Handler + }, + }, + } + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{Text: "/show model"}) + if res.Outcome != OutcomePassthrough { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } +} diff --git a/pkg/commands/handler_agents.go b/pkg/commands/handler_agents.go new file mode 100644 index 000000000..c459516eb --- /dev/null +++ b/pkg/commands/handler_agents.go @@ -0,0 +1,21 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +// agentsHandler returns a shared handler for both /show agents and /list agents. +func agentsHandler() Handler { + return func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListAgentIDs == nil { + return req.Reply(unavailableMsg) + } + ids := rt.ListAgentIDs() + if len(ids) == 0 { + return req.Reply("No agents registered") + } + return req.Reply(fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", "))) + } +} diff --git a/pkg/commands/registry.go b/pkg/commands/registry.go new file mode 100644 index 000000000..e17d489a6 --- /dev/null +++ b/pkg/commands/registry.go @@ -0,0 +1,55 @@ +package commands + +type Registry struct { + defs []Definition + index map[string]int +} + +// NewRegistry stores the canonical command set used by both dispatch and +// optional platform registration adapters. +func NewRegistry(defs []Definition) *Registry { + stored := make([]Definition, len(defs)) + copy(stored, defs) + + index := make(map[string]int, len(stored)*2) + for i, def := range stored { + registerCommandName(index, def.Name, i) + for _, alias := range def.Aliases { + registerCommandName(index, alias, i) + } + } + + return &Registry{defs: stored, index: index} +} + +// Definitions returns all registered command definitions. +// Command availability is global and no longer channel-scoped. +func (r *Registry) Definitions() []Definition { + out := make([]Definition, len(r.defs)) + copy(out, r.defs) + return out +} + +// Lookup returns a command definition by normalized command name or alias. +func (r *Registry) Lookup(name string) (Definition, bool) { + key := normalizeCommandName(name) + if key == "" { + return Definition{}, false + } + idx, ok := r.index[key] + if !ok { + return Definition{}, false + } + return r.defs[idx], true +} + +func registerCommandName(index map[string]int, name string, defIndex int) { + key := normalizeCommandName(name) + if key == "" { + return + } + if _, exists := index[key]; exists { + return + } + index[key] = defIndex +} diff --git a/pkg/commands/registry_test.go b/pkg/commands/registry_test.go new file mode 100644 index 000000000..bfff76b7c --- /dev/null +++ b/pkg/commands/registry_test.go @@ -0,0 +1,49 @@ +package commands + +import "testing" + +func TestRegistry_Definitions_ReturnsCopy(t *testing.T) { + defs := []Definition{ + {Name: "help", Description: "Show help"}, + {Name: "admin", Description: "Admin command"}, + } + r := NewRegistry(defs) + + got := r.Definitions() + if len(got) != 2 { + t.Fatalf("definitions len = %d, want 2", len(got)) + } + + got[0].Name = "mutated" + again := r.Definitions() + if again[0].Name != "help" { + t.Fatalf("registry should not be mutated by caller, got first name %q", again[0].Name) + } +} + +func TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) { + r := NewRegistry([]Definition{ + {Name: "Help", Aliases: []string{"Assist"}}, + {Name: "List"}, + }) + + def, ok := r.Lookup("help") + if !ok || def.Name != "Help" { + t.Fatalf("lookup by lowercase name failed: ok=%v def=%+v", ok, def) + } + + def, ok = r.Lookup("HELP") + if !ok || def.Name != "Help" { + t.Fatalf("lookup by uppercase name failed: ok=%v def=%+v", ok, def) + } + + def, ok = r.Lookup("assist") + if !ok || def.Name != "Help" { + t.Fatalf("lookup by lowercase alias failed: ok=%v def=%+v", ok, def) + } + + def, ok = r.Lookup("ASSIST") + if !ok || def.Name != "Help" { + t.Fatalf("lookup by uppercase alias failed: ok=%v def=%+v", ok, def) + } +} diff --git a/pkg/commands/request.go b/pkg/commands/request.go new file mode 100644 index 000000000..62ee600f2 --- /dev/null +++ b/pkg/commands/request.go @@ -0,0 +1,75 @@ +package commands + +import ( + "context" + "strings" +) + +type Handler func(ctx context.Context, req Request, rt *Runtime) error + +type Request struct { + Channel string + ChatID string + SenderID string + Text string + Reply func(text string) error +} + +const unavailableMsg = "Command unavailable in current context." + +var commandPrefixes = []string{"/", "!"} + +// parseCommandName accepts "/name", "!name", and Telegram's "/name@bot", then +// normalizes to lowercase command names. +func parseCommandName(input string) (string, bool) { + token := nthToken(input, 0) + if token == "" { + return "", false + } + + name, ok := trimCommandPrefix(token) + if !ok { + return "", false + } + if i := strings.Index(name, "@"); i >= 0 { + name = name[:i] + } + name = normalizeCommandName(name) + if name == "" { + return "", false + } + return name, true +} + +func trimCommandPrefix(token string) (string, bool) { + for _, prefix := range commandPrefixes { + if strings.HasPrefix(token, prefix) { + return strings.TrimPrefix(token, prefix), true + } + } + return "", false +} + +// HasCommandPrefix returns true if the input starts with a recognized +// command prefix (e.g. "/" or "!"). +func HasCommandPrefix(input string) bool { + token := nthToken(input, 0) + if token == "" { + return false + } + _, ok := trimCommandPrefix(token) + return ok +} + +// nthToken returns the 0-indexed token from whitespace-split input. +func nthToken(input string, n int) string { + parts := strings.Fields(strings.TrimSpace(input)) + if n >= len(parts) { + return "" + } + return parts[n] +} + +func normalizeCommandName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/pkg/commands/request_test.go b/pkg/commands/request_test.go new file mode 100644 index 000000000..4389e453b --- /dev/null +++ b/pkg/commands/request_test.go @@ -0,0 +1,28 @@ +package commands + +import "testing" + +func TestHasCommandPrefix(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"/help", true}, + {"!help", true}, + {"/switch model to gpt-4", true}, + {"!switch model to gpt-4", true}, + {"hello", false}, + {"", false}, + {" ", false}, + {"hello /world", false}, + {"/", true}, + {"!", true}, + {" /help", true}, + } + for _, tt := range tests { + got := HasCommandPrefix(tt.input) + if got != tt.want { + t.Errorf("HasCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go new file mode 100644 index 000000000..227d495f4 --- /dev/null +++ b/pkg/commands/runtime.go @@ -0,0 +1,16 @@ +package commands + +import "github.com/sipeed/picoclaw/pkg/config" + +// Runtime provides runtime dependencies to command handlers. It is constructed +// per-request by the agent loop so that per-request state (like session scope) +// can coexist with long-lived callbacks (like GetModelInfo). +type Runtime struct { + Config *config.Config + GetModelInfo func() (name, provider string) + ListAgentIDs func() []string + ListDefinitions func() []Definition + GetEnabledChannels func() []string + SwitchModel func(value string) (oldModel string, err error) + SwitchChannel func(value string) error +} diff --git a/pkg/commands/show_list_handlers_test.go b/pkg/commands/show_list_handlers_test.go new file mode 100644 index 000000000..047708f0f --- /dev/null +++ b/pkg/commands/show_list_handlers_test.go @@ -0,0 +1,85 @@ +package commands + +import ( + "context" + "strings" + "testing" +) + +func TestShowListHandlers_ChannelPolicy(t *testing.T) { + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil) + + var telegramReply string + handled := ex.Execute(context.Background(), Request{ + Channel: "telegram", + Text: "/show channel", + Reply: func(text string) error { + telegramReply = text + return nil + }, + }) + if handled.Outcome != OutcomeHandled { + t.Fatalf("telegram /show outcome=%v, want=%v", handled.Outcome, OutcomeHandled) + } + if telegramReply != "Current Channel: telegram" { + t.Fatalf("telegram /show reply=%q, want=%q", telegramReply, "Current Channel: telegram") + } + + var whatsappReply string + handledWhatsApp := ex.Execute(context.Background(), Request{ + Channel: "whatsapp", + Text: "/show channel", + Reply: func(text string) error { + whatsappReply = text + return nil + }, + }) + if handledWhatsApp.Outcome != OutcomeHandled { + t.Fatalf("whatsapp /show outcome=%v, want=%v", handledWhatsApp.Outcome, OutcomeHandled) + } + if handledWhatsApp.Command != "show" { + t.Fatalf("whatsapp /show command=%q, want=%q", handledWhatsApp.Command, "show") + } + if whatsappReply != "Current Channel: whatsapp" { + t.Fatalf("whatsapp /show reply=%q, want=%q", whatsappReply, "Current Channel: whatsapp") + } + + passthrough := ex.Execute(context.Background(), Request{ + Channel: "whatsapp", + Text: "/foo", + }) + if passthrough.Outcome != OutcomePassthrough { + t.Fatalf("whatsapp /foo outcome=%v, want=%v", passthrough.Outcome, OutcomePassthrough) + } + if passthrough.Command != "foo" { + t.Fatalf("whatsapp /foo command=%q, want=%q", passthrough.Command, "foo") + } +} + +func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { + rt := &Runtime{ + GetEnabledChannels: func() []string { + return []string{"telegram"} + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Channel: "whatsapp", + Text: "/list channels", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("whatsapp /list outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if res.Command != "list" { + t.Fatalf("whatsapp /list command=%q, want=%q", res.Command, "list") + } + if !strings.Contains(reply, "telegram") { + t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply) + } +} From aa2d6b39f523a0e606c38609d2195bd5ed7e918f Mon Sep 17 00:00:00 2001 From: mosir Date: Fri, 6 Mar 2026 18:34:46 +0800 Subject: [PATCH 67/73] refactor(tools): remove redundant kill -9 pattern --- pkg/tools/shell.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a41279280..b8a811d03 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -60,7 +60,6 @@ var ( regexp.MustCompile(`\bpkill\b`), regexp.MustCompile(`\bkillall\b`), regexp.MustCompile(`\bkill\b`), - regexp.MustCompile(`\bkill\s+-[9]\b`), regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), regexp.MustCompile(`\bnpm\s+install\s+-g\b`), From ac37d6b6264fb40941fe1b355509064a254dff4d Mon Sep 17 00:00:00 2001 From: wangyanfu2 Date: Fri, 6 Mar 2026 18:41:22 +0800 Subject: [PATCH 68/73] fix: disable closing custom model dialog by clicking backdrop (#1180) --- cmd/picoclaw-launcher/internal/ui/index.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html index 93893fd75..d84fd4e6e 100644 --- a/cmd/picoclaw-launcher/internal/ui/index.html +++ b/cmd/picoclaw-launcher/internal/ui/index.html @@ -1392,9 +1392,7 @@ function saveModelFromModal() { saveConfig().then(renderModels); } -document.getElementById('modelModal').addEventListener('click', function(e) { - if (e.target === this) closeModelModal(); -}); + // ── Channel Forms ─────────────────────────────────── function renderChannelForm(chKey) { From c368b5b3599c918fb2c1c7cf99639b63c61264d9 Mon Sep 17 00:00:00 2001 From: shikihane <48197860+shikihane@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:42:52 +0800 Subject: [PATCH 69/73] feat(feishu,tools): add outbound media delivery via send_file tool (#1156) * feat(feishu): implement SendMedia and add send_file tool Add outbound media support for the Feishu channel so the agent can send images and files to users via the MediaStore pipeline. Feishu channel: - SendMedia dispatches media parts as image or file uploads - sendImage uploads via Image.Create then sends image message - sendFile uploads via File.Create then sends file message - feishuFileType maps extensions to Feishu file_type values send_file tool: - New tool lets the LLM send a local file to the current chat - Validates path, registers file in MediaStore, returns media ref - Agent loop wires tool registration, MediaStore propagation, and context updates Tested on Radxa Cubie A7A (arm64) with Feishu websocket channel. Co-Authored-By: Claude Opus 4.6 * fix(agent): publish outbound media regardless of SendResponse flag The SendResponse flag controls whether the agent loop publishes the final text response (callers that publish it themselves set this to false). However, the media publish path was also gated behind this flag, which meant tool-produced media was silently dropped for normal channel messages. Media should be published immediately when a tool returns media refs, independent of how the text response is delivered. Co-Authored-By: Claude Opus 4.6 * fix(tools): use magic-bytes MIME detection and add file size limit to send_file - Replace hardcoded extension-to-MIME map with h2non/filetype (magic bytes) + mime.TypeByExtension fallback, consistent with the vision pipeline in resolveMediaRefs - Add configurable max file size check (defaults to config.DefaultMaxMediaSize, 20 MB) to prevent oversized uploads - Add tests for magic-bytes detection, extension fallback, size limit, and default max size Co-Authored-By: Claude Opus 4.6 * refactor(agent): add ForEachTool to AgentRegistry for cross-agent tool lookup Extract the pattern of iterating agents to find a named tool into AgentRegistry.ForEachTool, simplifying SetMediaStore propagation. Co-Authored-By: Claude Opus 4.6 * fix(agent,tools): adapt send_file to ctx-based channel injection after upstream refactor Replace ContextualTool interface (removed upstream) with direct ctx reading in SendFileTool.Execute, using ToolChannel/ToolChatID helpers. Remove updateToolContexts which is no longer needed since ExecuteWithContext already injects channel/chatID into ctx for all tools. Co-Authored-By: Claude Opus 4.6 * feat(tools): support toggling send_file tool via config Add SendFileConfig with Enabled field to ToolsConfig, defaulting to true. Wrap send_file tool registration in loop.go with the config check, consistent with the pattern used by other tools. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- pkg/agent/loop.go | 20 +++- pkg/agent/registry.go | 14 +++ pkg/config/config.go | 3 + pkg/config/defaults.go | 3 + pkg/tools/send_file.go | 150 ++++++++++++++++++++++++++++++ pkg/tools/send_file_test.go | 176 ++++++++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 pkg/tools/send_file.go create mode 100644 pkg/tools/send_file_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 966668227..19d13b2bb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -183,6 +183,17 @@ func registerSharedTools( agent.Tools.Register(messageTool) } + // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) + if cfg.Tools.IsToolEnabled("send_file") { + sendFileTool := tools.NewSendFileTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + ) + agent.Tools.Register(sendFileTool) + } + // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") @@ -384,6 +395,13 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { // SetMediaStore injects a MediaStore for media lifecycle management. func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s + + // Propagate store to send_file tools in all agents. + al.registry.ForEachTool("send_file", func(t tools.Tool) { + if sf, ok := t.(*tools.SendFileTool); ok { + sf.SetMediaStore(s) + } + }) } // SetTranscriber injects a voice transcriber for agent-level audio transcription. @@ -1167,7 +1185,7 @@ func (al *AgentLoop) runLLMIteration( } // If tool returned media refs, publish them as outbound media - if len(r.result.Media) > 0 && opts.SendResponse { + if len(r.result.Media) > 0 { parts := make([]bus.MediaPart, 0, len(r.result.Media)) for _, ref := range r.result.Media { part := bus.MediaPart{Ref: ref} diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 77b846832..0e7973dc3 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -7,6 +7,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/tools" ) // AgentRegistry manages multiple agent instances and routes messages to them. @@ -100,6 +101,19 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo return false } +// ForEachTool calls fn for every tool registered under the given name +// across all agents. This is useful for propagating dependencies (e.g. +// MediaStore) to tools after registry construction. +func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, agent := range r.agents { + if t, ok := agent.Tools.Get(name); ok { + fn(t) + } + } +} + // GetDefaultAgent returns the default agent instance. func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() diff --git a/pkg/config/config.go b/pkg/config/config.go index cff81a3a7..72af3e2fb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -640,6 +640,7 @@ type ToolsConfig struct { ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` @@ -913,6 +914,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Subagent.Enabled case "web_fetch": return t.WebFetch.Enabled + case "send_file": + return t.SendFile.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c4c04d41a..1902480c5 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -404,6 +404,9 @@ func DefaultConfig() *Config { TTLSeconds: 300, }, }, + SendFile: ToolConfig{ + Enabled: true, + }, MCP: MCPConfig{ ToolConfig: ToolConfig{ Enabled: false, diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go new file mode 100644 index 000000000..1a03e58ed --- /dev/null +++ b/pkg/tools/send_file.go @@ -0,0 +1,150 @@ +package tools + +import ( + "context" + "fmt" + "mime" + "os" + "path/filepath" + "strings" + + "github.com/h2non/filetype" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +// SendFileTool allows the LLM to send a local file (image, document, etc.) +// to the user on the current chat channel via the MediaStore pipeline. +type SendFileTool struct { + workspace string + restrict bool + maxFileSize int + mediaStore media.MediaStore + + defaultChannel string + defaultChatID string +} + +func NewSendFileTool(workspace string, restrict bool, maxFileSize int, store media.MediaStore) *SendFileTool { + if maxFileSize <= 0 { + maxFileSize = config.DefaultMaxMediaSize + } + return &SendFileTool{ + workspace: workspace, + restrict: restrict, + maxFileSize: maxFileSize, + mediaStore: store, + } +} + +func (t *SendFileTool) Name() string { return "send_file" } +func (t *SendFileTool) Description() string { + return "Send a local file (image, document, etc.) to the user on the current chat channel." +} + +func (t *SendFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the local file. Relative paths are resolved from workspace.", + }, + "filename": map[string]any{ + "type": "string", + "description": "Optional display filename. Defaults to the basename of path.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *SendFileTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *SendFileTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, _ := args["path"].(string) + if strings.TrimSpace(path) == "" { + return ErrorResult("path is required") + } + + // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values. + channel := ToolChannel(ctx) + if channel == "" { + channel = t.defaultChannel + } + chatID := ToolChatID(ctx) + if chatID == "" { + chatID = t.defaultChatID + } + if channel == "" || chatID == "" { + return ErrorResult("no target channel/chat available") + } + + if t.mediaStore == nil { + return ErrorResult("media store not configured") + } + + resolved, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } + + info, err := os.Stat(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("file not found: %v", err)) + } + if info.IsDir() { + return ErrorResult("path is a directory, expected a file") + } + if info.Size() > int64(t.maxFileSize) { + return ErrorResult(fmt.Sprintf( + "file too large: %d bytes (max %d bytes)", + info.Size(), t.maxFileSize, + )) + } + + filename, _ := args["filename"].(string) + if filename == "" { + filename = filepath.Base(resolved) + } + + mediaType := detectMediaType(resolved) + scope := fmt.Sprintf("tool:send_file:%s:%s", channel, chatID) + + ref, err := t.mediaStore.Store(resolved, media.MediaMeta{ + Filename: filename, + ContentType: mediaType, + Source: "tool:send_file", + }, scope) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register media: %v", err)) + } + + return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}) +} + +// detectMediaType determines the MIME type of a file. +// Uses magic-bytes detection (h2non/filetype) first, then falls back to +// extension-based lookup via mime.TypeByExtension. +func detectMediaType(path string) string { + kind, err := filetype.MatchFile(path) + if err == nil && kind != filetype.Unknown { + return kind.MIME.Value + } + + if ext := filepath.Ext(path); ext != "" { + if t := mime.TypeByExtension(ext); t != "" { + return t + } + } + + return "application/octet-stream" +} diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go new file mode 100644 index 000000000..08d129674 --- /dev/null +++ b/pkg/tools/send_file_test.go @@ -0,0 +1,176 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestSendFileTool_MissingPath(t *testing.T) { + store := media.NewFileMediaStore() + tool := NewSendFileTool("/tmp", false, 0, store) + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{}) + if !result.IsError { + t.Fatal("expected error for missing path") + } +} + +func TestSendFileTool_NoContext(t *testing.T) { + store := media.NewFileMediaStore() + tool := NewSendFileTool("/tmp", false, 0, store) + // no SetContext call + + result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"}) + if !result.IsError { + t.Fatal("expected error when no channel context") + } +} + +func TestSendFileTool_NoMediaStore(t *testing.T) { + tool := NewSendFileTool("/tmp", false, 0, nil) + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"}) + if !result.IsError { + t.Fatal("expected error when no media store") + } +} + +func TestSendFileTool_Directory(t *testing.T) { + store := media.NewFileMediaStore() + tool := NewSendFileTool("/tmp", false, 0, store) + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"}) + if !result.IsError { + t.Fatal("expected error for directory path") + } +} + +func TestSendFileTool_FileTooLarge(t *testing.T) { + dir := t.TempDir() + testFile := filepath.Join(dir, "big.bin") + // Create a file larger than the limit + if err := os.WriteFile(testFile, make([]byte, 1024), 0o644); err != nil { + t.Fatal(err) + } + + store := media.NewFileMediaStore() + tool := NewSendFileTool(dir, false, 512, store) // 512 byte limit + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{"path": testFile}) + if !result.IsError { + t.Fatal("expected error for oversized file") + } + if !strings.Contains(result.ForLLM, "too large") { + t.Errorf("expected 'too large' in error, got %q", result.ForLLM) + } +} + +func TestSendFileTool_DefaultMaxSize(t *testing.T) { + tool := NewSendFileTool("/tmp", false, 0, nil) + if tool.maxFileSize != config.DefaultMaxMediaSize { + t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) + } +} + +func TestSendFileTool_Success(t *testing.T) { + dir := t.TempDir() + testFile := filepath.Join(dir, "photo.png") + if err := os.WriteFile(testFile, []byte("fake png"), 0o644); err != nil { + t.Fatal(err) + } + + store := media.NewFileMediaStore() + tool := NewSendFileTool(dir, false, 0, store) + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{"path": testFile}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } + if result.Media[0][:8] != "media://" { + t.Errorf("expected media:// ref, got %q", result.Media[0]) + } +} + +func TestSendFileTool_CustomFilename(t *testing.T) { + dir := t.TempDir() + testFile := filepath.Join(dir, "img.jpg") + if err := os.WriteFile(testFile, []byte("fake jpg"), 0o644); err != nil { + t.Fatal(err) + } + + store := media.NewFileMediaStore() + tool := NewSendFileTool(dir, false, 0, store) + tool.SetContext("telegram", "chat456") + + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "filename": "my-photo.jpg", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } +} + +func TestDetectMediaType_MagicBytes(t *testing.T) { + dir := t.TempDir() + + // Minimal valid PNG header + pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + pngFile := filepath.Join(dir, "image.dat") // wrong extension, but valid PNG bytes + if err := os.WriteFile(pngFile, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + + got := detectMediaType(pngFile) + if got != "image/png" { + t.Errorf("expected image/png from magic bytes, got %q", got) + } +} + +func TestDetectMediaType_FallbackToExtension(t *testing.T) { + dir := t.TempDir() + + // File with unrecognizable content but known extension + txtFile := filepath.Join(dir, "readme.txt") + if err := os.WriteFile(txtFile, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + got := detectMediaType(txtFile) + // text/plain or similar — just verify it's not application/octet-stream + if got == "application/octet-stream" { + t.Errorf("expected extension-based MIME for .txt, got %q", got) + } +} + +func TestDetectMediaType_UnknownFallsToOctetStream(t *testing.T) { + dir := t.TempDir() + + // File with no extension and random bytes + unknownFile := filepath.Join(dir, "mystery") + if err := os.WriteFile(unknownFile, []byte{0x00, 0x01, 0x02}, 0o644); err != nil { + t.Fatal(err) + } + + got := detectMediaType(unknownFile) + if got != "application/octet-stream" { + t.Errorf("expected application/octet-stream, got %q", got) + } +} From c3af1543db7664c704ef42c89ac93aa5fe56cfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E8=88=AA?= <50691042+easyzoom@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:54:56 +0800 Subject: [PATCH 70/73] feat(build): add MIPS32 LE (mipsle) cross-compilation support (#1051) --- Makefile | 36 ++++++++++++++++++++++++++++++++++++ README.fr.md | 4 ++-- README.ja.md | 4 ++-- README.md | 4 ++-- README.pt-br.md | 4 ++-- README.vi.md | 4 ++-- README.zh.md | 4 ++-- 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index afc76a6ad..8de98e984 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,28 @@ LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(G GO?=CGO_ENABLED=0 go GOFLAGS?=-v -tags stdjson +# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). +# +# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404 +# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2 +# 0x00001000 EF_MIPS_ABI_O32 O32 ABI +# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding +# 0x00000004 EF_MIPS_CPIC PIC calling sequence +# +# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant +# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check. +# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way. +# +# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html +define PATCH_MIPS_FLAGS + @if [ -f "$(1)" ]; then \ + printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \ + { echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \ + else \ + echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \ + fi +endef + # Golangci-lint GOLANGCI_LINT?=golangci-lint @@ -50,6 +72,8 @@ ifeq ($(UNAME_S),Linux) ARCH=loong64 else ifeq ($(UNAME_M),riscv64) ARCH=riscv64 + else ifeq ($(UNAME_M),mipsel) + ARCH=mipsle else ARCH=$(UNAME_M) endif @@ -97,6 +121,8 @@ build-whatsapp-native: generate GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) ## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) @@ -117,6 +143,14 @@ build-linux-arm64: generate GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" +## build-linux-mipsle: Build for Linux MIPS32 LE +build-linux-mipsle: generate + @echo "Building for linux/mipsle (softfloat)..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle" + ## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit) build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" @@ -130,6 +164,8 @@ build-all: generate GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) diff --git a/README.fr.md b/README.fr.md index 320aa9e22..08a1926b6 100644 --- a/README.fr.md +++ b/README.fr.md @@ -7,7 +7,7 @@

Go - Hardware + Hardware License
Website @@ -65,7 +65,7 @@ ⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz. -🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti ! +🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti ! 🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle. diff --git a/README.ja.md b/README.ja.md index ea6bc7e72..c4c5b27a0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -8,7 +8,7 @@

Go -Hardware +Hardware License

@@ -49,7 +49,7 @@ ⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。 -🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go! +🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go! 🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。 diff --git a/README.md b/README.md index 3774055b4..db127a85f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Go - Hardware + Hardware License
Website @@ -69,7 +69,7 @@ ⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core. -🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go! +🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go! 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. diff --git a/README.pt-br.md b/README.pt-br.md index 67ce9e0d3..5f37ba457 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -7,7 +7,7 @@

Go - Hardware + Hardware License
Website @@ -66,7 +66,7 @@ ⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. -🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era! +🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era! 🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop. diff --git a/README.vi.md b/README.vi.md index 5755896ed..92c6ecbae 100644 --- a/README.vi.md +++ b/README.vi.md @@ -7,7 +7,7 @@

Go - Hardware + Hardware License
Website @@ -65,7 +65,7 @@ ⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz. -🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy! +🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy! 🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người. diff --git a/README.zh.md b/README.zh.md index dc32b67e0..d42b3cbb8 100644 --- a/README.zh.md +++ b/README.zh.md @@ -7,7 +7,7 @@

Go - Hardware + Hardware License
Website @@ -67,7 +67,7 @@ ⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 -🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行! +🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行! 🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 From 23abbb67ea378d59d9384ce88bc32a1d1aa2ad9a Mon Sep 17 00:00:00 2001 From: BallerIsLeet <138079832+BallerIsLeet@users.noreply.github.com> Date: Fri, 6 Mar 2026 06:58:23 -0500 Subject: [PATCH 71/73] feat(auth): add Anthropic OAuth setup-token login (#926) * feat(auth): add Anthropic OAuth setup-token login flow Add support for Anthropic's OAuth-based setup tokens (sk-ant-oat01-*) as an alternative to API keys. This includes: - New `--setup-token` flag on `auth login` command - Interactive login menu for Anthropic (setup token vs API key) - Setup token validation and credential storage with oauth auth method - Usage endpoint integration to show 5h/7d utilization in `auth status` - Streaming support for OAuth tokens (required by Anthropic API) - Model ID normalization (dots to hyphens) for API compatibility - Remove .env.example (secrets should not be templated) Co-Authored-By: Claude Opus 4.6 * feat(auth): update related functionality * refactor(auth): organize constants and improve header casing in requests fo CI * fix(auth): fix golint again * fix(auth): handle nil arguments in tool calls for buildParams function --------- Co-authored-by: Baller Co-authored-by: Claude Opus 4.6 --- .env.example | 5 +- cmd/picoclaw/internal/auth/helpers.go | 102 +++++++++++++++++++++-- cmd/picoclaw/internal/auth/login.go | 7 +- pkg/auth/anthropic_usage.go | 71 ++++++++++++++++ pkg/auth/anthropic_usage_test.go | 98 ++++++++++++++++++++++ pkg/auth/token.go | 29 +++++++ pkg/auth/token_test.go | 61 ++++++++++++++ pkg/providers/anthropic/provider.go | 54 +++++++++++- pkg/providers/anthropic/provider_test.go | 63 +++++++++++++- 9 files changed, 472 insertions(+), 18 deletions(-) create mode 100644 pkg/auth/anthropic_usage.go create mode 100644 pkg/auth/anthropic_usage_test.go create mode 100644 pkg/auth/token_test.go diff --git a/.env.example b/.env.example index bc68456d6..e899d2adc 100644 --- a/.env.example +++ b/.env.example @@ -5,13 +5,10 @@ # ANTHROPIC_API_KEY=sk-ant-xxx # OPENAI_API_KEY=sk-xxx # GEMINI_API_KEY=xxx -# CEREBRAS_API_KEY=xxx - +# CLAUDE_CODE_OAUTH=xxx # ── Chat Channel ────────────────────────── # TELEGRAM_BOT_TOKEN=123456:ABC... # DISCORD_BOT_TOKEN=xxx -# LINE_CHANNEL_SECRET=xxx -# LINE_CHANNEL_ACCESS_TOKEN=xxx # ── Web Search (optional) ──────────────── # BRAVE_SEARCH_API_KEY=BSA... diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 4dfbc92e7..a0a229167 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -1,6 +1,7 @@ package auth import ( + "bufio" "encoding/json" "fmt" "io" @@ -15,14 +16,17 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" +const ( + supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" + defaultAnthropicModel = "claude-sonnet-4.6" +) -func authLoginCmd(provider string, useDeviceCode bool) error { +func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { switch provider { case "openai": return authLoginOpenAI(useDeviceCode) case "anthropic": - return authLoginPasteToken(provider) + return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() default: @@ -163,6 +167,81 @@ func authLoginGoogleAntigravity() error { return nil } +func authLoginAnthropic(useOauth bool) error { + if useOauth { + return authLoginAnthropicSetupToken() + } + + fmt.Println("Anthropic login method:") + fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)") + fmt.Println(" 2) API key (from console.anthropic.com)") + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("Choose [1]: ") + choice := "1" + if scanner.Scan() { + text := strings.TrimSpace(scanner.Text()) + if text != "" { + choice = text + } + } + + switch choice { + case "1": + return authLoginAnthropicSetupToken() + case "2": + return authLoginPasteToken("anthropic") + default: + fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice) + } + } +} + +func authLoginAnthropicSetupToken() error { + cred, err := auth.LoginSetupToken(os.Stdin) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + if err = auth.SetCredential("anthropic", cred); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + appCfg, err := internal.LoadConfig() + if err == nil { + appCfg.Providers.Anthropic.AuthMethod = "oauth" + + found := false + for i := range appCfg.ModelList { + if isAnthropicModel(appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "oauth" + found = true + break + } + } + if !found { + appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + ModelName: defaultAnthropicModel, + Model: "anthropic/" + defaultAnthropicModel, + AuthMethod: "oauth", + }) + // Only set default model if user has no default configured yet + if appCfg.Agents.Defaults.GetModelName() == "" { + appCfg.Agents.Defaults.ModelName = defaultAnthropicModel + } + } + + if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { + return fmt.Errorf("could not update config: %w", err) + } + } + + fmt.Println("Setup token saved for Anthropic!") + + return nil +} + func fetchGoogleUserEmail(accessToken string) (string, error) { req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) if err != nil { @@ -220,13 +299,12 @@ func authLoginPasteToken(provider string) error { } if !found { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ - ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", + ModelName: defaultAnthropicModel, + Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "token", }) + appCfg.Agents.Defaults.ModelName = defaultAnthropicModel } - // Update default model - appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" case "openai": appCfg.Providers.OpenAI.AuthMethod = "token" // Update ModelList @@ -363,6 +441,16 @@ func authStatusCmd() error { if !cred.ExpiresAt.IsZero() { fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) } + + if provider == "anthropic" && cred.AuthMethod == "oauth" { + usage, err := auth.FetchAnthropicUsage(cred.AccessToken) + if err != nil { + fmt.Printf(" Usage: unavailable (%v)\n", err) + } else { + fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100) + fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100) + } + } } return nil diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index 9a6d28d2f..afbe098aa 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command { var ( provider string useDeviceCode bool + useOauth bool ) cmd := &cobra.Command{ @@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command { Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return authLoginCmd(provider, useDeviceCode) + return authLoginCmd(provider, useDeviceCode, useOauth) }, } cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") + cmd.Flags().BoolVar( + &useOauth, "setup-token", false, + "Use setup-token flow for Anthropic (from `claude setup-token`)", + ) _ = cmd.MarkFlagRequired("provider") return cmd diff --git a/pkg/auth/anthropic_usage.go b/pkg/auth/anthropic_usage.go new file mode 100644 index 000000000..716b2908e --- /dev/null +++ b/pkg/auth/anthropic_usage.go @@ -0,0 +1,71 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + anthropicBetaHeader = "oauth-2025-04-20" + anthropicAPIVersion = "2023-06-01" +) + +// anthropicUsageURL is the endpoint for fetching OAuth usage stats. +// It is a var (not const) to allow overriding in tests. +var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage" + +func setAnthropicUsageURL(url string) { anthropicUsageURL = url } + +type AnthropicUsage struct { + FiveHourUtilization float64 + SevenDayUtilization float64 +} + +func FetchAnthropicUsage(token string) (*AnthropicUsage, error) { + req, err := http.NewRequest("GET", anthropicUsageURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Anthropic-Version", anthropicAPIVersion) + req.Header.Set("Anthropic-Beta", anthropicBetaHeader) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading usage response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope") + } + return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + FiveHour struct { + Utilization float64 `json:"utilization"` + } `json:"five_hour"` + SevenDay struct { + Utilization float64 `json:"utilization"` + } `json:"seven_day"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing usage response: %w", err) + } + + return &AnthropicUsage{ + FiveHourUtilization: result.FiveHour.Utilization, + SevenDayUtilization: result.SevenDay.Utilization, + }, nil +} diff --git a/pkg/auth/anthropic_usage_test.go b/pkg/auth/anthropic_usage_test.go new file mode 100644 index 000000000..ef4a35364 --- /dev/null +++ b/pkg/auth/anthropic_usage_test.go @@ -0,0 +1,98 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchAnthropicUsage_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") + } + if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader { + t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`)) + })) + defer srv.Close() + + // Temporarily override the URL by using the test server + origURL := anthropicUsageURL + defer func() { setAnthropicUsageURL(origURL) }() + setAnthropicUsageURL(srv.URL) + + usage, err := FetchAnthropicUsage("test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage.FiveHourUtilization != 0.42 { + t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization) + } + if usage.SevenDayUtilization != 0.85 { + t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization) + } +} + +func TestFetchAnthropicUsage_Forbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"forbidden"}`)) + })) + defer srv.Close() + + origURL := anthropicUsageURL + defer func() { setAnthropicUsageURL(origURL) }() + setAnthropicUsageURL(srv.URL) + + _, err := FetchAnthropicUsage("test-token") + if err == nil { + t.Fatal("expected error for 403, got nil") + } + if !strings.Contains(err.Error(), "insufficient scope") { + t.Errorf("expected 'insufficient scope' error, got %q", err.Error()) + } +} + +func TestFetchAnthropicUsage_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`internal error`)) + })) + defer srv.Close() + + origURL := anthropicUsageURL + defer func() { setAnthropicUsageURL(origURL) }() + setAnthropicUsageURL(srv.URL) + + _, err := FetchAnthropicUsage("test-token") + if err == nil { + t.Fatal("expected error for 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected error containing '500', got %q", err.Error()) + } +} + +func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not json`)) + })) + defer srv.Close() + + origURL := anthropicUsageURL + defer func() { setAnthropicUsageURL(origURL) }() + setAnthropicUsageURL(srv.URL) + + _, err := FetchAnthropicUsage("test-token") + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if !strings.Contains(err.Error(), "parsing usage response") { + t.Errorf("expected 'parsing usage response' error, got %q", err.Error()) + } +} diff --git a/pkg/auth/token.go b/pkg/auth/token.go index a5a13ff03..0e69e60ac 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -31,6 +31,35 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) { }, nil } +func LoginSetupToken(r io.Reader) (*AuthCredential, error) { + fmt.Println("Paste your setup token from `claude setup-token`:") + fmt.Print("> ") + + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading token: %w", err) + } + return nil, fmt.Errorf("no input received") + } + + token := strings.TrimSpace(scanner.Text()) + + if !strings.HasPrefix(token, "sk-ant-oat01-") { + return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-") + } + + if len(token) < 80 { + return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)") + } + + return &AuthCredential{ + AccessToken: token, + Provider: "anthropic", + AuthMethod: "oauth", + }, nil +} + func providerDisplayName(provider string) string { switch provider { case "anthropic": diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go new file mode 100644 index 000000000..673cd9d5d --- /dev/null +++ b/pkg/auth/token_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestLoginSetupToken(t *testing.T) { + // A valid token: correct prefix + at least 80 chars + validToken := "sk-ant-oat01-" + strings.Repeat("a", 80) + + tests := []struct { + name string + input string + wantErr string + }{ + {"valid token", validToken, ""}, + {"empty input", "", "expected prefix sk-ant-oat01-"}, + {"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"}, + {"too short", "sk-ant-oat01-short", "too short"}, + {"whitespace only", " ", "expected prefix sk-ant-oat01-"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.input + "\n") + cred, err := LoginSetupToken(r) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cred.AccessToken != validToken { + t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken) + } + if cred.Provider != "anthropic" { + t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic") + } + if cred.AuthMethod != "oauth" { + t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth") + } + }) + } +} + +func TestLoginSetupToken_EmptyReader(t *testing.T) { + r := strings.NewReader("") + _, err := LoginSetupToken(r) + if err == nil { + t.Fatal("expected error for empty reader, got nil") + } +} diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 1b250b9b4..242ded175 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -23,7 +23,10 @@ type ( ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ) -const defaultBaseURL = "https://api.anthropic.com" +const ( + defaultBaseURL = "https://api.anthropic.com" + anthropicBetaHeader = "oauth-2025-04-20" +) type Provider struct { client *anthropic.Client @@ -80,7 +83,10 @@ func (p *Provider) Chat( if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } - opts = append(opts, option.WithAuthToken(tok)) + opts = append(opts, + option.WithAuthToken(tok), + option.WithHeader("anthropic-beta", anthropicBetaHeader), + ) } params, err := buildParams(messages, tools, model, options) @@ -88,6 +94,11 @@ func (p *Provider) Chat( return nil, err } + // OAuth/setup-tokens require streaming; API keys use non-streaming. + if p.tokenSource != nil { + return p.chatStreaming(ctx, params, opts) + } + resp, err := p.client.Messages.New(ctx, params, opts...) if err != nil { return nil, fmt.Errorf("claude API call: %w", err) @@ -96,6 +107,28 @@ func (p *Provider) Chat( return parseResponse(resp), nil } +func (p *Provider) chatStreaming( + ctx context.Context, + params anthropic.MessageNewParams, + opts []option.RequestOption, +) (*LLMResponse, error) { + stream := p.client.Messages.NewStreaming(ctx, params, opts...) + defer stream.Close() + + var msg anthropic.Message + for stream.Next() { + event := stream.Current() + if err := msg.Accumulate(event); err != nil { + return nil, fmt.Errorf("claude streaming accumulate: %w", err) + } + } + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("claude API call: %w", err) + } + + return parseResponse(&msg), nil +} + func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4.6" } @@ -147,7 +180,16 @@ func buildParams( blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) } for _, tc := range msg.ToolCalls { - blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) + args := tc.Arguments + if args == nil && tc.Function != nil && tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{} + } + } + if args == nil { + args = map[string]any{} + } + blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name)) } anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) } else { @@ -167,8 +209,12 @@ func buildParams( maxTokens = int64(mt) } + // Normalize model ID: Anthropic API uses hyphens (claude-sonnet-4-6), + // but config may use dots (claude-sonnet-4.6). + apiModel := strings.ReplaceAll(model, ".", "-") + params := anthropic.MessageNewParams{ - Model: anthropic.Model(model), + Model: anthropic.Model(apiModel), Messages: anthropicMessages, MaxTokens: maxTokens, } diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 3d21c1d0b..b1aed17b5 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -21,8 +21,8 @@ func TestBuildParams_BasicMessage(t *testing.T) { if err != nil { t.Fatalf("buildParams() error: %v", err) } - if string(params.Model) != "claude-sonnet-4.6" { - t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6") + if string(params.Model) != "claude-sonnet-4-6" { + t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6") } if params.MaxTokens != 1024 { t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) @@ -262,6 +262,65 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) { } } +func TestProvider_ChatStreamingRoundTrip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" { + t.Errorf("Authorization = %q, want %q", got, "Bearer refreshed-token") + } + if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader { + t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader) + } + + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + + events := []string{ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + } + for _, e := range events { + w.Write([]byte(e)) + if flusher != nil { + flusher.Flush() + } + } + })) + defer server.Close() + + p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) { + return "refreshed-token", nil + }, server.URL) + + resp, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "Hello"}}, + nil, + "claude-sonnet-4.6", + map[string]any{}, + ) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hello world" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello world") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage.CompletionTokens != 5 { + t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) + } +} + func createAnthropicTestClient(baseURL, token string) *anthropic.Client { c := anthropic.NewClient( anthropicoption.WithAuthToken(token), From 7f6d95c026703835cd72f5613a456b0a4b4eacc7 Mon Sep 17 00:00:00 2001 From: Qiaochu Hu <110hqc@gmail.com> Date: Fri, 6 Mar 2026 20:11:08 +0800 Subject: [PATCH 72/73] fix: handle zero values in cron schedule type assertions (#1147) Fixes #1126 Go type assertions return true for zero values, which caused recurring cron jobs (every_seconds/cron_expr) to silently become one-time 'at' tasks when LLMs filled unused optional parameters with default values (0). Changes: - Add validity checks after type assertions: atSeconds > 0, everySeconds > 0, cronExpr != "" - This ensures zero values are treated as 'not set' rather than valid schedule values - Recurring tasks like "remind me every 2 hours" now correctly create recurring jobs --- pkg/tools/cron.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 31ac9ab88..6af0aa9e1 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -141,6 +141,12 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult everySeconds, hasEvery := args["every_seconds"].(float64) cronExpr, hasCron := args["cron_expr"].(string) + // Fix: type assertions return true for zero values, need additional validity checks + // This prevents LLMs that fill unused optional parameters with defaults (0) from triggering wrong type + hasAt = hasAt && atSeconds > 0 + hasEvery = hasEvery && everySeconds > 0 + hasCron = hasCron && cronExpr != "" + // Priority: at_seconds > every_seconds > cron_expr if hasAt { atMS := time.Now().UnixMilli() + int64(atSeconds)*1000 From 1945436dd44817bb3eca9919e63dc8d7f1325c25 Mon Sep 17 00:00:00 2001 From: fishtrees Date: Fri, 6 Mar 2026 20:46:52 +0800 Subject: [PATCH 73/73] feat(cron): add execution lifecycle logging (#1185) - Log job start with name, id, schedule kind, and channel - Log job completion with duration and next run time - Log job errors with duration and error message - Helps diagnose scheduler stalls and connection issues --- pkg/cron/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 6962041c1..04775ac42 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -190,14 +190,21 @@ func (cs *CronService) executeJobByID(jobID string) { cs.mu.RUnlock() if callbackJob == nil { + log.Printf("[cron] job %s not found, skipping", jobID) return } + // Log job execution start + log.Printf("[cron] ▶ executing job '%s' (id: %s, schedule: %s, channel: %s)", + callbackJob.Name, jobID, callbackJob.Schedule.Kind, callbackJob.Payload.Channel) + var err error if cs.onJob != nil { _, err = cs.onJob(callbackJob) } + execDuration := time.Now().UnixMilli() - startTime + // Now acquire lock to update state cs.mu.Lock() defer cs.mu.Unlock() @@ -220,22 +227,35 @@ func (cs *CronService) executeJobByID(jobID string) { if err != nil { job.State.LastStatus = "error" job.State.LastError = err.Error() + log.Printf("[cron] ✗ job '%s' failed after %dms: %v", job.Name, execDuration, err) } else { job.State.LastStatus = "ok" job.State.LastError = "" } // Compute next run time + var nextRunStr string if job.Schedule.Kind == "at" { if job.DeleteAfterRun { cs.removeJobUnsafe(job.ID) + nextRunStr = "(deleted)" } else { job.Enabled = false job.State.NextRunAtMS = nil + nextRunStr = "(disabled)" } } else { nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli()) job.State.NextRunAtMS = nextRun + if nextRun != nil { + nextRunStr = time.UnixMilli(*nextRun).Format("2006-01-02 15:04:05") + } else { + nextRunStr = "(none)" + } + } + + if err == nil { + log.Printf("[cron] ✓ job '%s' completed in %dms, next run: %s", job.Name, execDuration, nextRunStr) } if err := cs.saveStoreUnsafe(); err != nil {