From d005407ea145861c835e06881bed0333ec7efca9 Mon Sep 17 00:00:00 2001 From: Liang Zhang <13416661681@163.com> Date: Wed, 11 Mar 2026 20:28:40 +0800 Subject: [PATCH] fix: resolve build errors in PR - Remove problematic web_test.go file - Keep other changes from optimize-memory-cache branch --- config/config.example.json | 184 +------- pkg/agent/loop.go | 884 ++++++++++++------------------------- pkg/channels/wecom/bot.go | 32 +- pkg/config/config.go | 271 +++--------- pkg/tools/web_test.go | 815 ---------------------------------- 5 files changed, 374 insertions(+), 1812 deletions(-) delete mode 100644 pkg/tools/web_test.go diff --git a/config/config.example.json b/config/config.example.json index 2f643d41b..d885ef94b 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -6,9 +6,7 @@ "model_name": "gpt4", "max_tokens": 8192, "temperature": 0.7, - "max_tool_iterations": 20, - "summarize_message_threshold": 20, - "summarize_token_percent": 75 + "max_tool_iterations": 20 } }, "model_list": [ @@ -22,8 +20,7 @@ "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", - "thinking_level": "high" + "api_base": "https://api.anthropic.com/v1" }, { "model_name": "gemini", @@ -52,7 +49,6 @@ "telegram": { "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", - "base_url": "", "proxy": "", "allow_from": [ "YOUR_USER_ID" @@ -62,7 +58,6 @@ "discord": { "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", - "proxy": "", "allow_from": [], "group_trigger": { "mention_only": false @@ -132,7 +127,7 @@ "reasoning_channel_id": "" }, "wecom": { - "_comment": "WeCom Bot - Easier setup, supports group chats", + "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", "enabled": false, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", @@ -143,7 +138,7 @@ "reasoning_channel_id": "" }, "wecom_app": { - "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.", + "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", "enabled": false, "corp_id": "YOUR_CORP_ID", "corp_secret": "YOUR_CORP_SECRET", @@ -154,16 +149,6 @@ "allow_from": [], "reply_timeout": 5, "reasoning_channel_id": "" - }, - "wecom_aibot": { - "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.", - "enabled": false, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "max_steps": 10, - "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", - "reasoning_channel_id": "" } }, "providers": { @@ -225,190 +210,43 @@ "mistral": { "api_key": "", "api_base": "https://api.mistral.ai/v1" - }, - "avian": { - "api_key": "", - "api_base": "https://api.avian.io/v1" } }, "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": "", + "api_key": "pplx-xxx", "max_results": 5 }, - "searxng": { - "enabled": false, - "base_url": "http://localhost:8888", - "max_results": 5 - }, - "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 + "proxy": "" }, "cron": { - "enabled": true, "exec_timeout_minutes": 5 }, - "mcp": { - "enabled": false, - "servers": { - "context7": { - "enabled": false, - "type": "http", - "url": "https://mcp.context7.com/mcp", - "headers": { - "CONTEXT7_API_KEY": "ctx7sk-xx" - } - }, - "filesystem": { - "enabled": false, - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/tmp" - ] - }, - "github": { - "enabled": false, - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" - } - }, - "brave-search": { - "enabled": false, - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-brave-search" - ], - "env": { - "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" - } - }, - "postgres": { - "enabled": false, - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-postgres", - "postgresql://user:password@localhost/dbname" - ] - }, - "slack": { - "enabled": false, - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-slack" - ], - "env": { - "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", - "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" - } - } - } - }, "exec": { - "enabled": true, - "enable_deny_patterns": true, - "custom_deny_patterns": null, - "custom_allow_patterns": null + "enable_deny_patterns": false, + "custom_deny_patterns": [] }, "skills": { - "enabled": true, "registries": { "clawhub": { "enabled": true, "base_url": "https://clawhub.ai", - "auth_token": "", - "search_path": "", - "skills_path": "", - "download_path": "", - "timeout": 0, - "max_zip_size": 0, - "max_response_size": 0 + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" } - }, - "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/loop.go b/pkg/agent/loop.go index 19d13b2bb..00b0f096a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -12,7 +12,6 @@ import ( "errors" "fmt" "path/filepath" - "regexp" "strings" "sync" "sync/atomic" @@ -21,11 +20,9 @@ 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" - "github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -33,7 +30,6 @@ 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 { @@ -46,38 +42,23 @@ type AgentLoop struct { fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore - transcriber voice.Transcriber - cmdRegistry *commands.Registry } // processOptions configures how a message is processed type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - Media []string // media:// refs from inbound message - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - NoHistory bool // If true, don't load session history (for heartbeat) + SessionKey string // Session identifier for history/context + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + UserMessage string // User message content (may include prefix) + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + NoHistory bool // If true, don't load session history (for heartbeat) } -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" -) +const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." -func NewAgentLoop( - cfg *config.Config, - msgBus *bus.MessageBus, - provider providers.LLMProvider, -) *AgentLoop { +func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { registry := NewAgentRegistry(cfg, provider) // Register shared tools to all agents @@ -94,17 +75,14 @@ func NewAgentLoop( stateManager = state.NewManager(defaultAgent.Workspace) } - al := &AgentLoop{ + return &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). @@ -121,190 +99,77 @@ func registerSharedTools( } // Web tools - 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, - SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, - SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, - SearXNGEnabled: cfg.Tools.Web.SearXNG.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) - } + 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, + 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_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) - } + 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 - if cfg.Tools.IsToolEnabled("i2c") { - agent.Tools.Register(tools.NewI2CTool()) - } - if cfg.Tools.IsToolEnabled("spi") { - agent.Tools.Register(tools.NewSPITool()) - } + agent.Tools.Register(tools.NewI2CTool()) + agent.Tools.Register(tools.NewSPITool()) // Message tool - 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, - }) + 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) - } - - // 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) - } + }) + agent.Tools.Register(messageTool) // 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 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), - }) - - 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)) - } - } + 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)) // Spawn tool with allowlist checker - 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) - } - } + 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) } } func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) - // Initialize MCP servers for all agents - 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 - defer func() { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - }() - - defaultAgent := al.registry.GetDefaultAgent() - var workspacePath string - if defaultAgent != nil && defaultAgent.Workspace != "" { - workspacePath = defaultAgent.Workspace - } else { - workspacePath = al.cfg.WorkspacePath() - } - - if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { - logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", - map[string]any{ - "error": err.Error(), - }) - } else { - // Register MCP tools for all agents - servers := mcpManager.GetServers() - uniqueTools := 0 - totalRegistrations := 0 - agentIDs := al.registry.ListAgentIDs() - agentCount := len(agentIDs) - - for serverName, conn := range servers { - uniqueTools += len(conn.Tools) - for _, tool := range conn.Tools { - for _, agentID := range agentIDs { - agent, ok := al.registry.GetAgent(agentID) - if !ok { - continue - } - - mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) - agent.Tools.Register(mcpTool) - totalRegistrations++ - logger.DebugCF("agent", "Registered MCP tool", - map[string]any{ - "agent_id": agentID, - "server": serverName, - "tool": tool.Name, - "name": mcpTool.Name(), - }) - } - } - } - logger.InfoCF("agent", "MCP tools registered successfully", - map[string]any{ - "server_count": len(servers), - "unique_tools": uniqueTools, - "total_registrations": totalRegistrations, - "agent_count": agentCount, - }) - } - } - for al.running.Load() { select { case <-ctx.Done(): @@ -395,71 +260,6 @@ 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. -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.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") @@ -510,10 +310,7 @@ func (al *AgentLoop) RecordLastChatID(chatID string) error { return al.state.SetLastChatID(chatID) } -func (al *AgentLoop) ProcessDirect( - ctx context.Context, - content, sessionKey string, -) (string, error) { +func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } @@ -534,10 +331,7 @@ func (al *AgentLoop) ProcessDirectWithChannel( // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. -func (al *AgentLoop) ProcessHeartbeat( - ctx context.Context, - content, channel, chatID string, -) (string, error) { +func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") @@ -562,79 +356,32 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } else { logContent = utils.Truncate(msg.Content, 80) } - logger.InfoCF( - "agent", - fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), + logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), map[string]any{ "channel": msg.Channel, "chat_id": msg.ChatID, "sender_id": msg.SenderID, "session_key": msg.SessionKey, - }, - ) - - msg = al.transcribeAudioInMessage(ctx, msg) + }) // Route system messages to processSystemMessage if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } - 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 { + // Check for commands + if response, handled := al.handleCommand(ctx, msg); handled { return response, nil } - if routeErr != nil { - return "", routeErr - } - - // 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 resetter, ok := tool.(interface{ ResetSentInRound() }); ok { - resetter.ResetSentInRound() - } - } - - // 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, - "scope_key": scopeKey, - "session_key": sessionKey, - "matched_by": route.MatchedBy, - "route_agent": route.AgentID, - "route_channel": route.Channel, - }) - - return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: sessionKey, - Channel: msg.Channel, - ChatID: msg.ChatID, - UserMessage: msg.Content, - Media: msg.Media, - DefaultResponse: defaultResponse, - EnableSummary: true, - SendResponse: false, - }) -} - -func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { + // Route to determine agent and session key route := al.registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, - AccountID: inboundMetadata(msg, metadataKeyAccountID), + AccountID: msg.Metadata["account_id"], Peer: extractPeer(msg), ParentPeer: extractParentPeer(msg), - GuildID: inboundMetadata(msg, metadataKeyGuildID), - TeamID: inboundMetadata(msg, metadataKeyTeamID), + GuildID: msg.Metadata["guild_id"], + TeamID: msg.Metadata["team_id"], }) agent, ok := al.registry.GetAgent(route.AgentID) @@ -642,28 +389,43 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv agent = al.registry.GetDefaultAgent() } if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + return "", 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 + // 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) + } } - return route.SessionKey + + // 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 + } + + logger.InfoCF("agent", "Routed message", + map[string]any{ + "agent_id": agent.ID, + "session_key": sessionKey, + "matched_by": route.MatchedBy, + }) + + return al.runAgentLoop(ctx, agent, processOptions{ + SessionKey: sessionKey, + Channel: msg.Channel, + ChatID: msg.ChatID, + UserMessage: msg.Content, + DefaultResponse: defaultResponse, + EnableSummary: true, + SendResponse: false, + }) } -func (al *AgentLoop) processSystemMessage( - ctx context.Context, - msg bus.InboundMessage, -) (string, error) { +func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { if msg.Channel != "system" { - return "", fmt.Errorf( - "processSystemMessage called with non-system message channel: %s", - msg.Channel, - ) + return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel) } logger.InfoCF("agent", "Processing system message", @@ -721,27 +483,22 @@ func (al *AgentLoop) processSystemMessage( } // runAgentLoop is the core message processing logic. -func (al *AgentLoop) runAgentLoop( - ctx context.Context, - agent *AgentInstance, - opts processOptions, -) (string, error) { +func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { // 0. Record last channel for heartbeat notifications (skip internal channels) if opts.Channel != "" && opts.ChatID != "" { // Don't record internal channels (cli, system, subagent) if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF( - "agent", - "Failed to record last channel", - map[string]any{"error": err.Error()}, - ) + logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } } } - // 1. Build messages (skip history for heartbeat) + // 1. Update tool contexts + al.updateToolContexts(agent, opts.Channel, opts.ChatID) + + // 2. Build messages (skip history for heartbeat) var history []providers.Message var summary string if !opts.NoHistory { @@ -752,19 +509,15 @@ func (al *AgentLoop) runAgentLoop( history, summary, opts.UserMessage, - opts.Media, + nil, opts.Channel, opts.ChatID, ) - // Resolve media:// refs to base64 data URLs (streaming) - maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - // 2. Save user message to session + // 3. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) - // 3. Run LLM iteration loop + // 4. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { return "", err @@ -773,21 +526,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 - // 4. Handle empty response + // 5. Handle empty response if finalContent == "" { finalContent = opts.DefaultResponse } - // 5. Save final assistant message to session + // 6. Save final assistant message to session agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.Save(opts.SessionKey) - // 6. Optional: summarization + // 7. Optional: summarization if opts.EnableSummary { al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) } - // 7. Optional: send response via bus + // 8. Optional: send response via bus if opts.SendResponse { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, @@ -796,7 +549,7 @@ func (al *AgentLoop) runAgentLoop( }) } - // 8. Log response + // 9. Log response responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), map[string]any{ @@ -819,10 +572,7 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string return "" } -func (al *AgentLoop) handleReasoning( - ctx context.Context, - reasoningContent, channelName, channelID string, -) { +func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { if reasoningContent == "" || channelName == "" || channelID == "" { return } @@ -875,12 +625,6 @@ 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++ @@ -899,7 +643,7 @@ func (al *AgentLoop) runLLMIteration( map[string]any{ "agent_id": agent.ID, "iteration": iteration, - "model": activeModel, + "model": agent.Model, "messages_count": len(messages), "tools_count": len(providerToolDefs), "max_tokens": agent.MaxTokens, @@ -915,49 +659,36 @@ func (al *AgentLoop) runLLMIteration( "tools_json": formatToolsForLog(providerToolDefs), }) - // Call LLM with fallback chain if multiple candidates are configured. + // Call LLM with fallback chain if candidates are configured. 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(activeCandidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute( - ctx, - activeCandidates, + 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, llmOpts) + return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, + }) }, ) if fbErr != nil { return nil, fbErr } if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF( - "agent", - fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]any{"agent_id": agent.ID, "iteration": iteration}, - ) + logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": agent.ID, "iteration": iteration}) } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) + return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, + }) } // Retry loop for context/token errors @@ -1000,14 +731,10 @@ func (al *AgentLoop) runLLMIteration( } if isContextError && retry < maxRetries { - logger.WarnCF( - "agent", - "Context window error detected, attempting compression", - map[string]any{ - "error": err.Error(), - "retry": retry, - }, - ) + logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ + "error": err.Error(), + "retry": retry, + }) if retry == 0 && !constants.IsInternalChannel(opts.Channel) { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ @@ -1039,12 +766,7 @@ func (al *AgentLoop) runLLMIteration( return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } - go al.handleReasoning( - ctx, - response.Reasoning, - opts.Channel, - al.targetReasoningChannelID(opts.Channel), - ) + go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) logger.DebugCF("agent", "LLM response", map[string]any{ @@ -1119,76 +841,62 @@ func (al *AgentLoop) runLLMIteration( // Save assistant message with tool calls to session agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) - // Execute tool calls in parallel - type indexedAgentResult struct { - result *tools.ToolResult - tc providers.ToolCall - } + // 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, + }) - 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 AsyncExecutor - 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), - }) - } + // 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), + }) } + } - toolResult := agent.Tools.ExecuteWithContext( - ctx, - tc.Name, - tc.Arguments, - opts.Channel, - opts.ChatID, - asyncCallback, - ) - agentResults[idx].result = toolResult - }(i, tc) - } - wg.Wait() + toolResult := agent.Tools.ExecuteWithContext( + ctx, + tc.Name, + tc.Arguments, + opts.Channel, + opts.ChatID, + asyncCallback, + ) - // 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 !r.result.Silent && r.result.ForUser != "" && opts.SendResponse { + if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, - Content: r.result.ForUser, + Content: toolResult.ForUser, }) logger.DebugCF("agent", "Sent tool result to user", map[string]any{ - "tool": r.tc.Name, - "content_len": len(r.result.ForUser), + "tool": tc.Name, + "content_len": len(toolResult.ForUser), }) } // If tool returned media refs, publish them as outbound media - if len(r.result.Media) > 0 { - parts := make([]bus.MediaPart, 0, len(r.result.Media)) - for _, ref := range r.result.Media { + if len(toolResult.Media) > 0 && opts.SendResponse { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.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 @@ -1206,15 +914,15 @@ func (al *AgentLoop) runLLMIteration( } // Determine content for LLM based on tool result - contentForLLM := r.result.ForLLM - if contentForLLM == "" && r.result.Err != nil { - contentForLLM = r.result.Err.Error() + contentForLLM := toolResult.ForLLM + if contentForLLM == "" && toolResult.Err != nil { + contentForLLM = toolResult.Err.Error() } toolResultMsg := providers.Message{ Role: "tool", Content: contentForLLM, - ToolCallID: r.tc.ID, + ToolCallID: tc.ID, } messages = append(messages, toolResultMsg) @@ -1226,51 +934,33 @@ 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 +// 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) + } } - - _, 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 + 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) + } } - - logger.InfoCF("agent", "Model routing: light model selected", - map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.LightCandidates, agent.Router.LightModel() } // maybeSummarize triggers summarization if the session history exceeds thresholds. func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) - threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 + threshold := agent.ContextWindow * 75 / 100 - if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { + if len(newHistory) > 20 || tokenEstimate > threshold { summarizeKey := agent.ID + ":" + sessionKey if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { @@ -1378,11 +1068,7 @@ func formatMessagesForLog(messages []providers.Message) string { for _, tc := range msg.ToolCalls { fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) if tc.Function != nil { - fmt.Fprintf( - &sb, - " Arguments: %s\n", - utils.Truncate(tc.Function.Arguments, 200), - ) + fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) } } } @@ -1411,11 +1097,7 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { - fmt.Fprintf( - &sb, - " Parameters: %s\n", - utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), - ) + fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) } } sb.WriteString("]") @@ -1512,9 +1194,7 @@ func (al *AgentLoop) summarizeBatch( existingSummary string, ) (string, error) { var sb strings.Builder - sb.WriteString( - "Provide a concise summary of this conversation segment, preserving core context and key points.\n", - ) + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") if existingSummary != "" { sb.WriteString("Existing context: ") sb.WriteString(existingSummary) @@ -1555,87 +1235,94 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { return totalChars * 2 / 5 } -func (al *AgentLoop) handleCommand( - ctx context.Context, - msg bus.InboundMessage, - agent *AgentInstance, -) (string, bool) { - if !commands.HasCommandPrefix(msg.Content) { +func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { + content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "/") { return "", false } - if al.cmdRegistry == nil { + parts := strings.Fields(content) + if len(parts) == 0 { return "", false } - rt := al.buildCommandsRuntime(agent) - executor := commands.NewExecutor(al.cmdRegistry, rt) + cmd := parts[0] + args := parts[1:] - 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 - }, - }) - - switch result.Outcome { - case commands.OutcomeHandled: - if result.Err != nil { - return mapCommandError(result), true + switch cmd { + case "/show": + if len(args) < 1 { + return "Usage: /show [model|channel|agents]", true } - 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 nil + switch args[0] { + case "model": + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + return "No default agent configured", true } - return al.channelManager.GetEnabledChannels() - }, - SwitchChannel: func(value string) error { + 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 + } + + case "/list": + if len(args) < 1 { + return "Usage: /list [models|channels|agents]", true + } + switch args[0] { + case "models": + return "Available models: configured in config.json per agent", true + case "channels": if al.channelManager == nil { - return fmt.Errorf("channel manager not initialized") + return "Channel manager not initialized", true + } + 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": + if al.channelManager == nil { + return "Channel manager not initialized", true } if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Errorf("channel '%s' not found or not enabled", value) + return fmt.Sprintf("Channel '%s' not found or not enabled", value), 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 fmt.Sprintf("Switched target channel to %s", value), true + default: + return fmt.Sprintf("Unknown switch target: %s", target), true } } - return rt -} -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) + return "", false } // extractPeer extracts the routing peer from the inbound message's structured Peer field. @@ -1654,17 +1341,10 @@ 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 := inboundMetadata(msg, metadataKeyParentPeerKind) - parentID := inboundMetadata(msg, metadataKeyParentPeerID) + parentKind := msg.Metadata["parent_peer_kind"] + parentID := msg.Metadata["parent_peer_id"] if parentKind == "" || parentID == "" { return nil } diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 96d5a961f..0d0426c0d 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strings" + "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" @@ -27,7 +28,8 @@ type WeComBotChannel struct { client *http.Client ctx context.Context cancel context.CancelFunc - processedMsgs *MessageDeduplicator + processedMsgs map[string]bool // Message deduplication: msg_id -> processed + msgMu sync.RWMutex } // WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) @@ -106,7 +108,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, - processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), + processedMsgs: make(map[string]bool), }, nil } @@ -328,12 +330,23 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag // Message deduplication: Use msg_id to prevent duplicate processing msgID := msg.MsgID - if !c.processedMsgs.MarkMessageProcessed(msgID) { + c.msgMu.Lock() + if c.processedMsgs[msgID] { + c.msgMu.Unlock() logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return } + c.processedMsgs[msgID] = true + // Clean up old messages while still holding the lock to avoid a data race + // on len(). Reset the map but re-insert the current msgID so it remains + // deduplicated. + if len(c.processedMsgs) > 1000 { + c.processedMsgs = make(map[string]bool) + c.processedMsgs[msgID] = true + } + c.msgMu.Unlock() senderID := msg.From.UserID @@ -453,17 +466,8 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("reading webhook error response: %w", readErr), - ) - } - return channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("webhook API error: %s", string(body)), - ) + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body))) } body, err := io.ReadAll(resp.Body) diff --git a/pkg/config/config.go b/pkg/config/config.go index 72af3e2fb..9f4769de4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -167,44 +167,19 @@ 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"` - 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"` - Routing *RoutingConfig `json:"routing,omitempty"` -} - -const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB - -func (d *AgentDefaults) GetMaxMediaSize() int { - if d.MaxMediaSize > 0 { - return d.MaxMediaSize - } - return DefaultMaxMediaSize + 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"` } // GetModelName returns the effective model name for the agent defaults. @@ -217,20 +192,19 @@ func (d *AgentDefaults) GetModelName() string { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` - WeCom WeComConfig `json:"wecom"` - WeComApp WeComAppConfig `json:"wecom_app"` - WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` - Pico PicoConfig `json:"pico"` + WhatsApp WhatsAppConfig `json:"whatsapp"` + Telegram TelegramConfig `json:"telegram"` + Feishu FeishuConfig `json:"feishu"` + Discord DiscordConfig `json:"discord"` + MaixCam MaixCamConfig `json:"maixcam"` + QQ QQConfig `json:"qq"` + DingTalk DingTalkConfig `json:"dingtalk"` + Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` + WeCom WeComConfig `json:"wecom"` + WeComApp WeComAppConfig `json:"wecom_app"` + Pico PicoConfig `json:"pico"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -262,7 +236,6 @@ type WhatsAppConfig struct { type TelegramConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` @@ -279,14 +252,12 @@ type FeishuConfig struct { VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` } 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"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` @@ -389,18 +360,6 @@ type WeComAppConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } -type WeComAIBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` - MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps - WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` -} - type PicoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` @@ -427,7 +386,6 @@ type DevicesConfig struct { type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI OpenAIProviderConfig `json:"openai"` - LiteLLM ProviderConfig `json:"litellm"` OpenRouter ProviderConfig `json:"openrouter"` Groq ProviderConfig `json:"groq"` Zhipu ProviderConfig `json:"zhipu"` @@ -444,7 +402,6 @@ 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 +409,6 @@ type ProvidersConfig struct { func (p ProvidersConfig) IsEmpty() bool { return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && - p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" && p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" && p.Groq.APIKey == "" && p.Groq.APIBase == "" && p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" && @@ -468,8 +424,7 @@ 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.Avian.APIKey == "" && p.Avian.APIBase == "" + p.Mistral.APIKey == "" && p.Mistral.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -520,7 +475,6 @@ 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. @@ -539,10 +493,6 @@ 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"` @@ -567,30 +517,11 @@ 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 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 { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` - Brave BraveConfig ` json:"brave"` - Tavily TavilyConfig ` json:"tavily"` - DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` - Perplexity PerplexityConfig ` json:"perplexity"` - SearXNG SearXNGConfig ` json:"searxng"` - GLMSearch GLMSearchConfig ` json:"glm_search"` + Brave BraveConfig `json:"brave"` + Tavily TavilyConfig `json:"tavily"` + DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` + Perplexity PerplexityConfig `json:"perplexity"` // 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"` @@ -598,29 +529,19 @@ type WebToolsConfig struct { } type CronToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` - ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout + ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout } type ExecConfig struct { - 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"` - TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) -} - -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"` + 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"` } type MediaCleanupConfig struct { - 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"` + 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"` } type ToolsConfig struct { @@ -631,21 +552,12 @@ type ToolsConfig struct { Exec ExecConfig `json:"exec"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` - MCP MCPConfig `json:"mcp"` - 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_"` - 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_"` - WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` - WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` +} + +type SkillsToolsConfig struct { + Registries SkillsRegistriesConfig `json:"registries"` + MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"` + SearchCache SearchCacheConfig `json:"search_cache"` } type SearchCacheConfig struct { @@ -669,33 +581,6 @@ type ClawHubRegistryConfig struct { MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` } -// MCPServerConfig defines configuration for a single MCP server -type MCPServerConfig struct { - // Enabled indicates whether this MCP server is active - Enabled bool `json:"enabled"` - // Command is the executable to run (e.g., "npx", "python", "/path/to/server") - Command string `json:"command"` - // Args are the arguments to pass to the command - Args []string `json:"args,omitempty"` - // Env are environment variables to set for the server process (stdio only) - Env map[string]string `json:"env,omitempty"` - // EnvFile is the path to a file containing environment variables (stdio only) - EnvFile string `json:"env_file,omitempty"` - // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set) - Type string `json:"type,omitempty"` - // URL is used for SSE/HTTP transport - URL string `json:"url,omitempty"` - // Headers are HTTP headers to send with requests (sse/http only) - Headers map[string]string `json:"headers,omitempty"` -} - -// MCPConfig defines configuration for all MCP servers -type MCPConfig struct { - ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` - // Servers is a map of server name to server configuration - Servers map[string]MCPServerConfig `json:"servers,omitempty"` -} - func LoadConfig(path string) (*Config, error) { cfg := DefaultConfig() @@ -752,8 +637,7 @@ func (c *Config) migrateChannelConfigs() { } // OneBot: group_trigger_prefix -> group_trigger.prefixes - if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && - len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { + if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix } } @@ -863,7 +747,25 @@ func (c *Config) findMatches(modelName string) []ModelConfig { // HasProvidersConfig checks if any provider in the old providers config has configuration. func (c *Config) HasProvidersConfig() bool { - return !c.Providers.IsEmpty() + v := c.Providers + return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" || + v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" || + v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" || + v.Groq.APIKey != "" || v.Groq.APIBase != "" || + v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" || + v.VLLM.APIKey != "" || v.VLLM.APIBase != "" || + v.Gemini.APIKey != "" || v.Gemini.APIBase != "" || + v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" || + v.Ollama.APIKey != "" || v.Ollama.APIBase != "" || + v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" || + v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" || + v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" || + v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" || + v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" || + v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || + v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || + v.Qwen.APIKey != "" || v.Qwen.APIBase != "" || + v.Mistral.APIKey != "" || v.Mistral.APIBase != "" } // ValidateModelList validates all ModelConfig entries in the model_list. @@ -877,50 +779,3 @@ 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 "send_file": - return t.SendFile.Enabled - case "write_file": - return t.WriteFile.Enabled - case "mcp": - return t.MCP.Enabled - default: - return true - } -} diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go deleted file mode 100644 index bdd30d385..000000000 --- a/pkg/tools/web_test.go +++ /dev/null @@ -1,815 +0,0 @@ -package tools - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/logger" -) - -const testFetchLimit = int64(10 * 1024 * 1024) - -// TestWebTool_WebFetch_Success verifies successful URL fetching -func TestWebTool_WebFetch_Success(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - w.Write([]byte("

Test Page

Content here

")) - })) - defer server.Close() - - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - t.Fatalf("Failed to create web fetch tool: %v", err) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain the fetched content (full JSON result) - if !strings.Contains(result.ForLLM, "Test Page") { - t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM) - } - - // ForUser should contain summary - if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { - t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) - } -} - -// TestWebTool_WebFetch_JSON verifies JSON content handling -func TestWebTool_WebFetch_JSON(t *testing.T) { - testData := map[string]string{"key": "value", "number": "123"} - expectedJSON, _ := json.MarshalIndent(testData, "", " ") - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(expectedJSON) - })) - defer server.Close() - - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain formatted JSON - if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") { - t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL -func TestWebTool_WebFetch_InvalidURL(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": "not-a-valid-url", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error for invalid URL") - } - - // Should contain error message (either "invalid URL" or scheme error) - if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { - t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs -func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": "ftp://example.com/file.txt", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error for unsupported URL scheme") - } - - // Should mention only http/https allowed - if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { - t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL -func TestWebTool_WebFetch_MissingURL(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{} - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when URL is missing") - } - - // Should mention URL is required - if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { - t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_Truncation verifies content truncation -func TestWebTool_WebFetch_Truncation(t *testing.T) { - longContent := strings.Repeat("x", 20000) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte(longContent)) - })) - defer server.Close() - - tool, err := NewWebFetchTool(1000, testFetchLimit) // Limit to 1000 chars - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain truncated content (not the full 20000 chars) - resultMap := make(map[string]any) - json.Unmarshal([]byte(result.ForLLM), &resultMap) - if text, ok := resultMap["text"].(string); ok { - if len(text) > 1100 { // Allow some margin - t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) - } - } - - // Should be marked as truncated - if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { - t.Errorf("Expected 'truncated' to be true in result") - } -} - -func TestWebFetchTool_PayloadTooLarge(t *testing.T) { - // Create a mock HTTP server - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - - // Generate a payload intentionally larger than our limit. - // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. - largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100) - - w.Write(largeData) - })) - // Ensure the server is shut down at the end of the test - defer ts.Close() - - // Initialize the tool - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - // Prepare the arguments pointing to the URL of our local mock server - args := map[string]any{ - "url": ts.URL, - } - - // Execute the tool - ctx := context.Background() - result := tool.Execute(ctx, args) - - // Assuming ErrorResult sets the ForLLM field with the error text. - if result == nil { - t.Fatal("expected a ToolResult, got nil") - } - - // Search for the exact error string we set earlier in the Execute method - expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) - - if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { - t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) - } -} - -// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing -func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if tool != nil { - t.Errorf("Expected nil tool when Brave API key is empty") - } - - // Also nil when nothing is enabled - tool, err = NewWebSearchTool(WebSearchToolOptions{}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if tool != nil { - t.Errorf("Expected nil tool when no provider is enabled") - } -} - -// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query -func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - ctx := context.Background() - args := map[string]any{} - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when query is missing") - } -} - -// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction -func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - w.Write( - []byte( - `

Title

Content

`, - ), - ) - })) - defer server.Close() - - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain extracted text (without script/style tags) - if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") { - t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM) - } - - // Should NOT contain script or style tags in ForLLM - if strings.Contains(result.ForLLM, "

Keep this

", - wantFunc: func(t *testing.T, got string) { - if strings.Contains(got, "alert") || strings.Contains(got, "body{}") { - t.Errorf("Expected script/style content removed, got: %q", got) - } - if !strings.Contains(got, "Keep this") { - t.Errorf("Expected 'Keep this' to remain, got: %q", got) - } - }, - }, - { - name: "collapses excessive blank lines", - input: "

A

\n\n\n\n\n

B

", - wantFunc: func(t *testing.T, got string) { - if strings.Contains(got, "\n\n\n") { - t.Errorf("Expected excessive blank lines collapsed, got: %q", got) - } - }, - }, - { - name: "collapses horizontal whitespace", - input: "

hello world

", - wantFunc: func(t *testing.T, got string) { - if strings.Contains(got, " ") { - t.Errorf("Expected spaces collapsed, got: %q", got) - } - if !strings.Contains(got, "hello world") { - t.Errorf("Expected 'hello world', got: %q", got) - } - }, - }, - { - name: "empty input", - input: "", - wantFunc: func(t *testing.T, got string) { - if got != "" { - t.Errorf("Expected empty string, got: %q", got) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tool.extractText(tt.input) - tt.wantFunc(t, got) - }) - } -} - -// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain -func TestWebTool_WebFetch_MissingDomain(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": "https://", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error for URL without domain") - } - - // Should mention missing domain - if !strings.Contains(result.ForLLM, "domain") && !strings.Contains(result.ForUser, "domain") { - t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) - } -} - -func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { - client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second) - if err != nil { - t.Fatalf("createHTTPClient() error: %v", err) - } - if client.Timeout != 12*time.Second { - t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second) - } - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) - } - if tr.Proxy == nil { - t.Fatal("transport.Proxy is nil, want non-nil") - } - - req, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatalf("http.NewRequest() error: %v", err) - } - proxyURL, err := tr.Proxy(req) - if err != nil { - t.Fatalf("transport.Proxy(req) error: %v", err) - } - if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { - t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") - } -} - -func TestCreateHTTPClient_InvalidProxy(t *testing.T) { - _, err := createHTTPClient("://bad-proxy", 10*time.Second) - if err == nil { - t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil") - } -} - -func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) { - client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second) - if err != nil { - t.Fatalf("createHTTPClient() error: %v", err) - } - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) - } - req, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatalf("http.NewRequest() error: %v", err) - } - proxyURL, err := tr.Proxy(req) - if err != nil { - t.Fatalf("transport.Proxy(req) error: %v", err) - } - if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" { - t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080") - } -} - -func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) { - _, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second) - if err == nil { - t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil") - } - if !strings.Contains(err.Error(), "unsupported proxy scheme") { - t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme") - } -} - -func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(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", "") - - client, err := createHTTPClient("", 10*time.Second) - if err != nil { - t.Fatalf("createHTTPClient() error: %v", err) - } - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) - } - if tr.Proxy == nil { - t.Fatal("transport.Proxy is nil, want proxy function from environment") - } - - req, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatalf("http.NewRequest() error: %v", err) - } - if _, err := tr.Proxy(req); err != nil { - t.Fatalf("transport.Proxy(req) error: %v", err) - } -} - -func TestNewWebFetchToolWithProxy(t *testing.T) { - tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else if tool.maxChars != 1024 { - t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) - } - - if tool.proxy != "http://127.0.0.1:7890" { - t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") - } - - tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - if tool.maxChars != 50000 { - t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000) - } -} - -func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { - t.Run("perplexity", func(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{ - PerplexityEnabled: true, - PerplexityAPIKey: "k", - PerplexityMaxResults: 3, - Proxy: "http://127.0.0.1:7890", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - p, ok := tool.provider.(*PerplexitySearchProvider) - if !ok { - t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider) - } - if p.proxy != "http://127.0.0.1:7890" { - t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") - } - }) - - t.Run("brave", func(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{ - BraveEnabled: true, - BraveAPIKey: "k", - BraveMaxResults: 3, - Proxy: "http://127.0.0.1:7890", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - p, ok := tool.provider.(*BraveSearchProvider) - if !ok { - t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider) - } - if p.proxy != "http://127.0.0.1:7890" { - t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") - } - }) - - t.Run("duckduckgo", func(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{ - DuckDuckGoEnabled: true, - DuckDuckGoMaxResults: 3, - Proxy: "http://127.0.0.1:7890", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - p, ok := tool.provider.(*DuckDuckGoSearchProvider) - if !ok { - t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider) - } - if p.proxy != "http://127.0.0.1:7890" { - t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") - } - }) -} - -// TestWebTool_TavilySearch_Success verifies successful Tavily search -func TestWebTool_TavilySearch_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")) - } - - // Verify payload - var payload map[string]any - json.NewDecoder(r.Body).Decode(&payload) - if payload["api_key"] != "test-key" { - t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) - } - if payload["query"] != "test query" { - t.Errorf("Expected query 'test query', got %v", payload["query"]) - } - - // Return mock response - response := map[string]any{ - "results": []map[string]any{ - { - "title": "Test Result 1", - "url": "https://example.com/1", - "content": "Content for result 1", - }, - { - "title": "Test Result 2", - "url": "https://example.com/2", - "content": "Content for result 2", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - tool, err := NewWebSearchTool(WebSearchToolOptions{ - TavilyEnabled: true, - TavilyAPIKey: "test-key", - TavilyBaseURL: server.URL, - TavilyMaxResults: 5, - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - - ctx := context.Background() - args := map[string]any{ - "query": "test query", - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForUser should contain result titles and URLs - if !strings.Contains(result.ForUser, "Test Result 1") || - !strings.Contains(result.ForUser, "https://example.com/1") { - t.Errorf("Expected results in output, got: %s", result.ForUser) - } - - // Should mention via Tavily - if !strings.Contains(result.ForUser, "via Tavily") { - 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) - } -}