diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 8734352d8..4610352c2 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -71,7 +71,8 @@ func gatewayCmd(debug bool) error { }) // Setup cron tool and service - execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute + cronCfg := cron_tool.GetCronConfig(cfg) + execTimeout := time.Duration(cronCfg.ExecTimeoutMinutes) * time.Minute cronService := setupCronTool( agentLoop, msgBus, @@ -233,8 +234,9 @@ func setupCronTool( cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - if cfg.Tools.Cron.Enabled { - cronTool := cron_tool.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) + cronToolCfg := cron_tool.GetCronConfig(cfg) + if cronToolCfg.Enabled { + cronTool := cron_tool.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg.GetTool("cron")) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index 439b81a4f..724d6cbca 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/tools/find_skills" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -62,10 +63,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName) - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) + skillsCfg := find_skills.GetSkillsConfig(cfg) + registryMgr := skills.NewRegistryManagerFromConfig(skillsCfg) registry := registryMgr.GetRegistry(registryName) if registry == nil { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5ed27ad05..5717a910e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -94,7 +94,7 @@ func registerSharedTools( } // Message tool - if cfg.Tools.Message.Enabled { + if cfg.ToolEnabled("message") { messageTool := message.NewMessageTool() messageTool.SetSendCallback(func(channel, chatID, content string) error { msgBus.PublishOutbound(bus.OutboundMessage{ @@ -108,7 +108,7 @@ func registerSharedTools( } // Spawn tool with allowlist checker - if cfg.Tools.Spawn.Enabled { + if cfg.ToolEnabled("spawn") { subagentManager := subagent.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) spawnTool := subagent.NewSpawnTool(subagentManager) diff --git a/pkg/config/config.go b/pkg/config/config.go index 9306e6978..2d6ec7d1c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,7 +55,8 @@ type Config struct { Providers ProvidersConfig `json:"providers,omitempty"` ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` + Tools ToolsConfig `json:"tools,omitempty"` + ToolList []ToolConfig `json:"tool_list"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` } @@ -468,7 +469,9 @@ type CronToolConfig struct { } type ToolConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_ENABLED"` // Default env var, can be overridden per tool + Name string `json:"name" env:"PICOCLAW_TOOLS_{{.Name}}_ENABLED"` // Used for env var parsing, not required in JSON if using struct field names + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_ENABLED"` + Extra map[string]any `json:"extra,omitempty"` // Catch-all for any additional tool-specific configs } type ExecConfig struct { @@ -564,6 +567,15 @@ func LoadConfig(path string) (*Config, error) { cfg.ModelList = nil } + // Pre-scan: if user provides legacy "tools" config, we need to clear the default + // ToolList so migration can properly convert tools to tool_list + var raw map[string]any + if err := json.Unmarshal(data, &raw); err == nil { + if _, hasTools := raw["tools"]; hasTools { + cfg.ToolList = nil + } + } + if err := json.Unmarshal(data, cfg); err != nil { return nil, err } @@ -577,6 +589,12 @@ func LoadConfig(path string) (*Config, error) { cfg.ModelList = ConvertProvidersToModelList(cfg) } + // Auto-migrate: convert legacy tools config to tool_list for backward compatibility + if len(cfg.ToolList) == 0 && cfg.hasToolsConfig() { + cfg.ToolList = ConvertToolsToToolList(cfg.Tools) + cfg.Tools = ToolsConfig{} + } + // Validate model_list for uniqueness and required fields if err := cfg.ValidateModelList(); err != nil { return nil, err @@ -595,6 +613,109 @@ func SaveConfig(path string, cfg *Config) error { return fileutil.WriteFileAtomic(path, data, 0o600) } +func (c *Config) hasToolsConfig() bool { + return c.Tools.Web.Enabled || c.Tools.Web.Proxy != "" || + c.Tools.Cron.Enabled || + c.Tools.ReadFile.Enabled || c.Tools.WriteFile.Enabled || + c.Tools.EditFile.Enabled || c.Tools.AppendFile.Enabled || + c.Tools.ListDir.Enabled || c.Tools.Exec.Enabled || + c.Tools.FindSkills.Enabled || c.Tools.InstallSkill.Enabled || + c.Tools.Spawn.Enabled || c.Tools.Message.Enabled || + c.Tools.I2C.Enabled || c.Tools.SPI.Enabled || + c.Tools.Skills.MaxConcurrentSearches > 0 +} + +func ConvertToolsToToolList(tools ToolsConfig) []ToolConfig { + var toolList []ToolConfig + + if tools.Web.Enabled || tools.Web.Proxy != "" || tools.Web.Brave.APIKey != "" || + tools.Web.Tavily.APIKey != "" || tools.Web.DuckDuckGo.Enabled || + tools.Web.Perplexity.APIKey != "" { + toolList = append(toolList, ToolConfig{Name: "web", Enabled: true, Extra: map[string]any{ + "brave": tools.Web.Brave, + "tavily": tools.Web.Tavily, + "duckduckgo": tools.Web.DuckDuckGo, + "perplexity": tools.Web.Perplexity, + "proxy": tools.Web.Proxy, + }}) + } + + if tools.Cron.Enabled { + toolList = append(toolList, ToolConfig{Name: "cron", Enabled: true, Extra: map[string]any{ + "exec_timeout_minutes": tools.Cron.ExecTimeoutMinutes, + }}) + } + + if tools.ReadFile.Enabled { + toolList = append(toolList, ToolConfig{Name: "read-file", Enabled: true}) + } + if tools.WriteFile.Enabled { + toolList = append(toolList, ToolConfig{Name: "write-file", Enabled: true}) + } + if tools.EditFile.Enabled { + toolList = append(toolList, ToolConfig{Name: "edit-file", Enabled: true}) + } + if tools.AppendFile.Enabled { + toolList = append(toolList, ToolConfig{Name: "append-file", Enabled: true}) + } + if tools.ListDir.Enabled { + toolList = append(toolList, ToolConfig{Name: "list-dir", Enabled: true}) + } + + if tools.Exec.Enabled { + toolList = append(toolList, ToolConfig{Name: "exec", Enabled: true, Extra: map[string]any{ + "enable_deny_patterns": tools.Exec.EnableDenyPatterns, + "custom_deny_patterns": tools.Exec.CustomDenyPatterns, + }}) + } + + if tools.FindSkills.Enabled { + toolList = append(toolList, ToolConfig{Name: "find-skills", Enabled: true}) + } + if tools.InstallSkill.Enabled { + toolList = append(toolList, ToolConfig{Name: "install-skill", Enabled: true}) + } + + if tools.Spawn.Enabled { + toolList = append(toolList, ToolConfig{Name: "spawn", Enabled: true}) + } + + if tools.Message.Enabled { + toolList = append(toolList, ToolConfig{Name: "message", Enabled: true}) + } + + if tools.I2C.Enabled { + toolList = append(toolList, ToolConfig{Name: "i2c", Enabled: true}) + } + if tools.SPI.Enabled { + toolList = append(toolList, ToolConfig{Name: "spi", Enabled: true}) + } + + if tools.Skills.MaxConcurrentSearches > 0 || tools.Skills.SearchCache.MaxSize > 0 { + toolList = append(toolList, ToolConfig{Name: "skills", Enabled: true, Extra: map[string]any{ + "registries": tools.Skills.Registries, + "max_concurrent_searches": tools.Skills.MaxConcurrentSearches, + "search_cache": tools.Skills.SearchCache, + }}) + } + + return toolList +} + +func (c *Config) GetTool(name string) *ToolConfig { + for i := range c.ToolList { + if c.ToolList[i].Name == name { + return &c.ToolList[i] + } + } + return nil +} + +func (c *Config) ToolEnabled(name string) bool { + tc := c.GetTool(name) + return tc != nil && tc.Enabled +} + func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index bf56b7f34..2018a456c 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -284,22 +284,6 @@ func TestDefaultConfig_Channels(t *testing.T) { } } -// TestDefaultConfig_WebTools verifies web tools config -func TestDefaultConfig_WebTools(t *testing.T) { - cfg := DefaultConfig() - - // Verify web tools defaults - if cfg.Tools.Web.Brave.MaxResults != 5 { - t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) - } - if cfg.Tools.Web.Brave.APIKey != "" { - t.Error("Brave API key should be empty by default") - } - if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { - t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) - } -} - func TestSaveConfig_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file permission bits are not enforced on Windows") @@ -393,27 +377,6 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { } } -func TestLoadConfig_WebToolsProxy(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "config.json") - configJSON := `{ - "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, - "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}], - "tools": {"web":{"proxy":"http://127.0.0.1:7890"}} -}` - if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { - t.Fatalf("os.WriteFile() error: %v", err) - } - - cfg, err := LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error: %v", err) - } - if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" { - t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890") - } -} - // TestDefaultConfig_DMScope verifies the default dm_scope value func TestDefaultConfig_DMScope(t *testing.T) { cfg := DefaultConfig() diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 60431608b..3ebfab0b5 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -275,85 +275,117 @@ func DefaultConfig() *Config { Host: "127.0.0.1", Port: 18790, }, - Tools: ToolsConfig{ - Web: WebToolsConfig{ - Proxy: "", - Brave: BraveConfig{ - Enabled: false, - APIKey: "", - MaxResults: 5, - }, - DuckDuckGo: DuckDuckGoConfig{ - Enabled: true, - MaxResults: 5, - }, - Perplexity: PerplexityConfig{ - Enabled: false, - APIKey: "", - MaxResults: 5, + ToolList: []ToolConfig{ + { + Name: "read-file", + Enabled: true, + }, + { + Name: "write-file", + Enabled: true, + }, + { + Name: "edit-file", + Enabled: false, + }, + { + Name: "append-file", + Enabled: false, + }, + { + Name: "list-dir", + Enabled: false, + }, + { + Name: "exec", + Enabled: true, + Extra: map[string]any{ + "enable_deny_patterns": true, + "custom_deny_patterns": []string{}, }, }, - Cron: CronToolConfig{ - Enabled: true, - ExecTimeoutMinutes: 5, - }, - // File tools - each individually configurable - ReadFile: ToolConfig{ + { + Name: "find-skills", Enabled: true, }, - WriteFile: ToolConfig{ + { + Name: "install-skill", Enabled: true, }, - EditFile: ToolConfig{ - Enabled: false, - }, - AppendFile: ToolConfig{ - Enabled: false, - }, - ListDir: ToolConfig{ - Enabled: false, - }, - // Exec tool - Exec: ExecConfig{ - Enabled: true, - EnableDenyPatterns: true, - }, - // Skills tools - FindSkills: ToolConfig{ + { + Name: "spawn", Enabled: true, }, - InstallSkill: ToolConfig{ + { + Name: "message", Enabled: true, }, - // Subagent tools - Spawn: ToolConfig{ + { + Name: "web", Enabled: true, - }, - // Message tool - Message: ToolConfig{ - Enabled: true, - }, - // Hardware tools - I2C: ToolConfig{ - Enabled: false, - }, - SPI: ToolConfig{ - Enabled: false, - }, - Skills: SkillsToolsConfig{ - Registries: SkillsRegistriesConfig{ - ClawHub: ClawHubRegistryConfig{ - Enabled: true, - BaseURL: "https://clawhub.ai", + Extra: map[string]any{ + "brave": map[string]any{ + "enabled": false, + "api_key": "", + "max_results": 5, }, + "tavily": map[string]any{ + "enabled": false, + "api_key": "", + "max_results": 5, + }, + "duckduckgo": map[string]any{ + "enabled": true, + "max_results": 5, + }, + "perplexity": map[string]any{ + "enabled": false, + "api_key": "", + "max_results": 5, + }, + "proxy": "", }, - MaxConcurrentSearches: 2, - SearchCache: SearchCacheConfig{ - MaxSize: 50, - TTLSeconds: 300, + }, + { + Name: "cron", + Enabled: true, + Extra: map[string]any{ + "exec_timeout_minutes": 5, + }, + }, + { + Name: "i2c", + Enabled: false, + }, + { + Name: "spi", + Enabled: false, + }, + { + Name: "skills", + Enabled: true, + Extra: map[string]any{ + "registries": map[string]any{ + "clawhub": map[string]any{ + "enabled": true, + "base_url": "https://clawhub.ai", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download", + "timeout": 30, + "max_zip_size": 10485760, + "max_response_size": 5242880, + }, + }, + "max_concurrent_searches": 2, + "search_cache": map[string]any{ + "max_size": 50, + "ttl_seconds": 300, + }, }, }, }, + Tools: ToolsConfig{}, Heartbeat: HeartbeatConfig{ Enabled: true, Interval: 30, diff --git a/pkg/config/parse.go b/pkg/config/parse.go new file mode 100644 index 000000000..643897b20 --- /dev/null +++ b/pkg/config/parse.go @@ -0,0 +1,88 @@ +package config + +func GetMap(data map[string]any, key string) (map[string]any, bool) { + v, ok := data[key] + if !ok { + return nil, false + } + m, ok := v.(map[string]any) + return m, ok +} + +func GetString(data map[string]any, key string) (string, bool) { + v, ok := data[key] + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} + +func GetStringOrDefault(data map[string]any, key string, defaultVal string) string { + if v, ok := GetString(data, key); ok { + return v + } + return defaultVal +} + +func GetFloat(data map[string]any, key string) (float64, bool) { + v, ok := data[key] + if !ok { + return 0, false + } + f, ok := v.(float64) + return f, ok +} + +func GetBool(data map[string]any, key string) (bool, bool) { + v, ok := data[key] + if !ok { + return false, false + } + b, ok := v.(bool) + return b, ok +} + +func GetBoolOrDefault(data map[string]any, key string, defaultVal bool) bool { + if v, ok := GetBool(data, key); ok { + return v + } + return defaultVal +} + +func GetStringSlice(data map[string]any, key string) []string { + v, ok := data[key] + if !ok { + return []string{} + } + arr, ok := v.([]any) + if !ok { + return []string{} + } + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result +} + +func GetInt(m map[string]any, key string) (int, bool) { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + } + } + return 0, false +} + +func GetIntOrDefault(data map[string]any, key string, defaultVal int) int { + if v, ok := GetInt(data, key); ok { + return v + } + return defaultVal +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 869b39827..783f84232 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -71,37 +71,37 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { cfg := config.DefaultConfig() var warnings []string - if agents, ok := getMap(data, "agents"); ok { - if defaults, ok := getMap(agents, "defaults"); ok { + if agents, ok := config.GetMap(data, "agents"); ok { + if defaults, ok := config.GetMap(agents, "defaults"); ok { // Prefer model_name, fallback to model for backward compatibility - if v, ok := getString(defaults, "model_name"); ok { + if v, ok := config.GetString(defaults, "model_name"); ok { cfg.Agents.Defaults.ModelName = v - } else if v, ok := getString(defaults, "model"); ok { + } else if v, ok := config.GetString(defaults, "model"); ok { cfg.Agents.Defaults.Model = v } - if v, ok := getFloat(defaults, "max_tokens"); ok { + if v, ok := config.GetFloat(defaults, "max_tokens"); ok { cfg.Agents.Defaults.MaxTokens = int(v) } - if v, ok := getFloat(defaults, "temperature"); ok { + if v, ok := config.GetFloat(defaults, "temperature"); ok { cfg.Agents.Defaults.Temperature = &v } - if v, ok := getFloat(defaults, "max_tool_iterations"); ok { + if v, ok := config.GetFloat(defaults, "max_tool_iterations"); ok { cfg.Agents.Defaults.MaxToolIterations = int(v) } - if v, ok := getString(defaults, "workspace"); ok { + if v, ok := config.GetString(defaults, "workspace"); ok { cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v) } } } - if providers, ok := getMap(data, "providers"); ok { + if providers, ok := config.GetMap(data, "providers"); ok { for name, val := range providers { pMap, ok := val.(map[string]any) if !ok { continue } - apiKey, _ := getString(pMap, "api_key") - apiBase, _ := getString(pMap, "api_base") + apiKey, _ := config.GetString(pMap, "api_key") + apiBase, _ := config.GetString(pMap, "api_base") if !supportedProviders[name] { if apiKey != "" || apiBase != "" { @@ -117,7 +117,7 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { case "openai": cfg.Providers.OpenAI = config.OpenAIProviderConfig{ ProviderConfig: pc, - WebSearch: getBoolOrDefault(pMap, "web_search", true), + WebSearch: config.GetBoolOrDefault(pMap, "web_search", true), } case "openrouter": cfg.Providers.OpenRouter = pc @@ -133,7 +133,7 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { } } - if channels, ok := getMap(data, "channels"); ok { + if channels, ok := config.GetMap(data, "channels"); ok { for name, val := range channels { cMap, ok := val.(map[string]any) if !ok { @@ -143,94 +143,94 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name)) continue } - enabled, _ := getBool(cMap, "enabled") - allowFrom := getStringSlice(cMap, "allow_from") + enabled, _ := config.GetBool(cMap, "enabled") + allowFrom := config.GetStringSlice(cMap, "allow_from") switch name { case "telegram": cfg.Channels.Telegram.Enabled = enabled cfg.Channels.Telegram.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { + if v, ok := config.GetString(cMap, "token"); ok { cfg.Channels.Telegram.Token = v } case "discord": cfg.Channels.Discord.Enabled = enabled cfg.Channels.Discord.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { + if v, ok := config.GetString(cMap, "token"); ok { cfg.Channels.Discord.Token = v } case "whatsapp": cfg.Channels.WhatsApp.Enabled = enabled cfg.Channels.WhatsApp.AllowFrom = allowFrom - if v, ok := getString(cMap, "bridge_url"); ok { + if v, ok := config.GetString(cMap, "bridge_url"); ok { cfg.Channels.WhatsApp.BridgeURL = v } case "feishu": cfg.Channels.Feishu.Enabled = enabled cfg.Channels.Feishu.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { + if v, ok := config.GetString(cMap, "app_id"); ok { cfg.Channels.Feishu.AppID = v } - if v, ok := getString(cMap, "app_secret"); ok { + if v, ok := config.GetString(cMap, "app_secret"); ok { cfg.Channels.Feishu.AppSecret = v } - if v, ok := getString(cMap, "encrypt_key"); ok { + if v, ok := config.GetString(cMap, "encrypt_key"); ok { cfg.Channels.Feishu.EncryptKey = v } - if v, ok := getString(cMap, "verification_token"); ok { + if v, ok := config.GetString(cMap, "verification_token"); ok { cfg.Channels.Feishu.VerificationToken = v } case "qq": cfg.Channels.QQ.Enabled = enabled cfg.Channels.QQ.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { + if v, ok := config.GetString(cMap, "app_id"); ok { cfg.Channels.QQ.AppID = v } - if v, ok := getString(cMap, "app_secret"); ok { + if v, ok := config.GetString(cMap, "app_secret"); ok { cfg.Channels.QQ.AppSecret = v } case "dingtalk": cfg.Channels.DingTalk.Enabled = enabled cfg.Channels.DingTalk.AllowFrom = allowFrom - if v, ok := getString(cMap, "client_id"); ok { + if v, ok := config.GetString(cMap, "client_id"); ok { cfg.Channels.DingTalk.ClientID = v } - if v, ok := getString(cMap, "client_secret"); ok { + if v, ok := config.GetString(cMap, "client_secret"); ok { cfg.Channels.DingTalk.ClientSecret = v } case "maixcam": cfg.Channels.MaixCam.Enabled = enabled cfg.Channels.MaixCam.AllowFrom = allowFrom - if v, ok := getString(cMap, "host"); ok { + if v, ok := config.GetString(cMap, "host"); ok { cfg.Channels.MaixCam.Host = v } - if v, ok := getFloat(cMap, "port"); ok { + if v, ok := config.GetFloat(cMap, "port"); ok { cfg.Channels.MaixCam.Port = int(v) } } } } - if gateway, ok := getMap(data, "gateway"); ok { - if v, ok := getString(gateway, "host"); ok { + if gateway, ok := config.GetMap(data, "gateway"); ok { + if v, ok := config.GetString(gateway, "host"); ok { cfg.Gateway.Host = v } - if v, ok := getFloat(gateway, "port"); ok { + if v, ok := config.GetFloat(gateway, "port"); ok { cfg.Gateway.Port = int(v) } } - if tools, ok := getMap(data, "tools"); ok { - if web, ok := getMap(tools, "web"); ok { + if tools, ok := config.GetMap(data, "tools"); ok { + if web, ok := config.GetMap(tools, "web"); ok { // Migrate old "search" config to "brave" if api_key is present - if search, ok := getMap(web, "search"); ok { - if v, ok := getString(search, "api_key"); ok { + if search, ok := config.GetMap(web, "search"); ok { + if v, ok := config.GetString(search, "api_key"); ok { cfg.Tools.Web.Brave.APIKey = v if v != "" { cfg.Tools.Web.Brave.Enabled = true } } - if v, ok := getFloat(search, "max_results"); ok { + if v, ok := config.GetFloat(search, "max_results"); ok { cfg.Tools.Web.Brave.MaxResults = int(v) cfg.Tools.Web.DuckDuckGo.MaxResults = int(v) } @@ -345,64 +345,3 @@ func rewriteWorkspacePath(path string) string { path = strings.Replace(path, ".openclaw", ".picoclaw", 1) return path } - -func getMap(data map[string]any, key string) (map[string]any, bool) { - v, ok := data[key] - if !ok { - return nil, false - } - m, ok := v.(map[string]any) - return m, ok -} - -func getString(data map[string]any, key string) (string, bool) { - v, ok := data[key] - if !ok { - return "", false - } - s, ok := v.(string) - return s, ok -} - -func getFloat(data map[string]any, key string) (float64, bool) { - v, ok := data[key] - if !ok { - return 0, false - } - f, ok := v.(float64) - return f, ok -} - -func getBool(data map[string]any, key string) (bool, bool) { - v, ok := data[key] - if !ok { - return false, false - } - b, ok := v.(bool) - return b, ok -} - -func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool { - if v, ok := getBool(data, key); ok { - return v - } - return defaultVal -} - -func getStringSlice(data map[string]any, key string) []string { - v, ok := data[key] - if !ok { - return []string{} - } - arr, ok := v.([]any) - if !ok { - return []string{} - } - result := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - return result -} diff --git a/pkg/tools/cron/config.go b/pkg/tools/cron/config.go new file mode 100644 index 000000000..d0a67b7b2 --- /dev/null +++ b/pkg/tools/cron/config.go @@ -0,0 +1,32 @@ +package cron + +import ( + "github.com/sipeed/picoclaw/pkg/config" +) + +type CronToolConfig struct { + Enabled bool + ExecTimeoutMinutes int +} + +func GetCronConfig(cfg *config.Config) CronToolConfig { + tc := cfg.GetTool("cron") + if tc == nil { + return CronToolConfig{} + } + return ParseCronConfig(tc) +} + +func ParseCronConfig(tc *config.ToolConfig) CronToolConfig { + if tc == nil || !tc.Enabled { + return CronToolConfig{} + } + extra := tc.Extra + if extra == nil { + return CronToolConfig{Enabled: true} + } + return CronToolConfig{ + Enabled: true, + ExecTimeoutMinutes: config.GetIntOrDefault(extra, "exec_timeout_minutes", 5), + } +} diff --git a/pkg/tools/cron/cron.go b/pkg/tools/cron/cron.go index dd2ea5a97..26167a986 100644 --- a/pkg/tools/cron/cron.go +++ b/pkg/tools/cron/cron.go @@ -34,10 +34,17 @@ type CronTool struct { // execTimeout: 0 means no timeout, >0 sets the timeout duration func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, - execTimeout time.Duration, config *config.Config, + execTimeout time.Duration, toolConfig *config.ToolConfig, ) *CronTool { - execTool := exec.NewExecToolWithConfig(workspace, restrict, config) - execTool.SetTimeout(execTimeout) + execTool := exec.NewExecToolWithConfig(workspace, restrict, toolConfig) + if execTimeout > 0 { + execTool.SetTimeout(execTimeout) + } else if toolConfig != nil { + cronCfg := ParseCronConfig(toolConfig) + if cronCfg.ExecTimeoutMinutes > 0 { + execTool.SetTimeout(time.Duration(cronCfg.ExecTimeoutMinutes) * time.Minute) + } + } return &CronTool{ cronService: cronService, executor: executor, diff --git a/pkg/tools/exec/config.go b/pkg/tools/exec/config.go new file mode 100644 index 000000000..6cbc37bd4 --- /dev/null +++ b/pkg/tools/exec/config.go @@ -0,0 +1,34 @@ +package exec + +import ( + "github.com/sipeed/picoclaw/pkg/config" +) + +type ExecToolConfig struct { + Enabled bool + EnableDenyPatterns bool + CustomDenyPatterns []string +} + +func GetExecConfig(cfg *config.Config) ExecToolConfig { + tc := cfg.GetTool("exec") + if tc == nil { + return ExecToolConfig{} + } + return ParseExecConfig(tc) +} + +func ParseExecConfig(tc *config.ToolConfig) ExecToolConfig { + if tc == nil || !tc.Enabled { + return ExecToolConfig{} + } + extra := tc.Extra + if extra == nil { + return ExecToolConfig{Enabled: true} + } + return ExecToolConfig{ + Enabled: true, + EnableDenyPatterns: config.GetBoolOrDefault(extra, "enable_deny_patterns", true), + CustomDenyPatterns: config.GetStringSlice(extra, "custom_deny_patterns"), + } +} diff --git a/pkg/tools/exec/exec.go b/pkg/tools/exec/exec.go index d1cae555d..9d43b67c8 100644 --- a/pkg/tools/exec/exec.go +++ b/pkg/tools/exec/exec.go @@ -74,11 +74,11 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { return NewExecToolWithConfig(workingDir, restrict, nil) } -func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool { +func NewExecToolWithConfig(workingDir string, restrict bool, toolConfig *config.ToolConfig) *ExecTool { denyPatterns := make([]*regexp.Regexp, 0) - if config != nil { - execConfig := config.Tools.Exec + if toolConfig != nil { + execConfig := ParseExecConfig(toolConfig) enableDenyPatterns := execConfig.EnableDenyPatterns if enableDenyPatterns { denyPatterns = append(denyPatterns, defaultDenyPatterns...) diff --git a/pkg/tools/find_skills/config.go b/pkg/tools/find_skills/config.go new file mode 100644 index 000000000..45aba0039 --- /dev/null +++ b/pkg/tools/find_skills/config.go @@ -0,0 +1,70 @@ +package find_skills + +import ( + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/skills" +) + +func GetSkillsConfig(cfg *config.Config) skills.RegistryConfig { + tc := cfg.GetTool("skills") + if tc == nil || !tc.Enabled { + return skills.RegistryConfig{} + } + extra := tc.Extra + if extra == nil { + return skills.RegistryConfig{} + } + skillsCfg := skills.RegistryConfig{} + + if v, ok := extra["registries"]; ok { + if m, ok := v.(map[string]any); ok { + if clawhub, ok := m["clawhub"]; ok { + if cm, ok := clawhub.(map[string]any); ok { + skillsCfg.ClawHub = skills.ClawHubConfig{ + Enabled: config.GetBoolOrDefault(cm, "enabled", false), + BaseURL: config.GetStringOrDefault(cm, "base_url", ""), + SearchPath: config.GetStringOrDefault(cm, "search_path", ""), + SkillsPath: config.GetStringOrDefault(cm, "skills_path", ""), + DownloadPath: config.GetStringOrDefault(cm, "download_path", ""), + Timeout: config.GetIntOrDefault(cm, "timeout", 30), + MaxZipSize: config.GetIntOrDefault(cm, "max_zip_size", 1024*1024*100), + MaxResponseSize: config.GetIntOrDefault(cm, "max_response_size", 1024*1024*50), + } + } + } + } + } + skillsCfg.MaxConcurrentSearches = config.GetIntOrDefault(extra, "max_concurrent_searches", 2) + return skillsCfg +} + +func GetSearchCache(cfg *config.Config) *skills.SearchCache { + maxSize, ttlSeconds := GetSearchCacheConfig(cfg) + return skills.NewSearchCache(maxSize, time.Duration(ttlSeconds)*time.Second) +} + +func GetSearchCacheConfig(cfg *config.Config) (maxSize int, ttlSeconds int) { + tc := cfg.GetTool("skills") + if tc == nil || !tc.Enabled { + return 50, 300 + } + extra := tc.Extra + if extra == nil { + return 50, 300 + } + if v, ok := extra["search_cache"]; ok { + if m, ok := v.(map[string]any); ok { + maxSize = config.GetIntOrDefault(m, "max_size", 50) + ttlSeconds = config.GetIntOrDefault(m, "ttl_seconds", 300) + } + } + if maxSize <= 0 { + maxSize = 50 + } + if ttlSeconds <= 0 { + ttlSeconds = 300 + } + return maxSize, ttlSeconds +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index a3519ae5f..54033584d 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -42,78 +42,74 @@ func NewToolRegistry(cfg *config.Config, workspace string, restrict bool) *ToolR } // File tools - each with individual configuration - if cfg.Tools.ReadFile.Enabled { + if cfg.ToolEnabled("read-file") { toolsRegistry.Register(read_file.NewReadFileTool(workspace, restrict)) } - if cfg.Tools.WriteFile.Enabled { + if cfg.ToolEnabled("write-file") { toolsRegistry.Register(write_file.NewWriteFileTool(workspace, restrict)) } - if cfg.Tools.EditFile.Enabled { + if cfg.ToolEnabled("edit-file") { toolsRegistry.Register(edit_file.NewEditFileTool(workspace, restrict)) } - if cfg.Tools.AppendFile.Enabled { + if cfg.ToolEnabled("append-file") { toolsRegistry.Register(append_file.NewAppendFileTool(workspace, restrict)) } - if cfg.Tools.ListDir.Enabled { + if cfg.ToolEnabled("list-dir") { toolsRegistry.Register(list_dir.NewListDirTool(workspace, restrict)) } // Exec tool - if cfg.Tools.Exec.Enabled { - toolsRegistry.Register(exec.NewExecToolWithConfig(workspace, restrict, cfg)) + if cfg.ToolEnabled("exec") { + toolsRegistry.Register(exec.NewExecToolWithConfig(workspace, restrict, cfg.GetTool("exec"))) } // Web tools + webCfg := web_search.GetWebToolsConfig(cfg) if searchTool := web_search.NewWebSearchTool(web_search.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, + BraveAPIKey: webCfg.Brave.APIKey, + BraveMaxResults: webCfg.Brave.MaxResults, + BraveEnabled: webCfg.Brave.Enabled, + TavilyAPIKey: webCfg.Tavily.APIKey, + TavilyBaseURL: webCfg.Tavily.BaseURL, + TavilyMaxResults: webCfg.Tavily.MaxResults, + TavilyEnabled: webCfg.Tavily.Enabled, + DuckDuckGoMaxResults: webCfg.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: webCfg.DuckDuckGo.Enabled, + PerplexityAPIKey: webCfg.Perplexity.APIKey, + PerplexityMaxResults: webCfg.Perplexity.MaxResults, + PerplexityEnabled: webCfg.Perplexity.Enabled, + Proxy: webCfg.Proxy, }); searchTool != nil { toolsRegistry.Register(searchTool) } - toolsRegistry.Register(web_fetch.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy)) + toolsRegistry.Register(web_fetch.NewWebFetchToolWithProxy(50000, webCfg.Proxy)) // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - if cfg.Tools.I2C.Enabled { + if cfg.ToolEnabled("i2c") { toolsRegistry.Register(i2c.NewI2CTool()) } - if cfg.Tools.SPI.Enabled { + if cfg.ToolEnabled("spi") { toolsRegistry.Register(spi.NewSPITool()) } // Skill discovery and installation tools - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) - searchCache := skills.NewSearchCache( - cfg.Tools.Skills.SearchCache.MaxSize, - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - if cfg.Tools.FindSkills.Enabled { + skillsCfg := find_skills.GetSkillsConfig(cfg) + registryMgr := skills.NewRegistryManagerFromConfig(skillsCfg) + searchCache := find_skills.GetSearchCache(cfg) + if cfg.ToolEnabled("find-skills") { toolsRegistry.Register(find_skills.NewFindSkillsTool(registryMgr, searchCache)) } - if cfg.Tools.InstallSkill.Enabled { + if cfg.ToolEnabled("install-skill") { toolsRegistry.Register(install_skill.NewInstallSkillTool(registryMgr, workspace)) } // Message tool - if cfg.Tools.Message.Enabled { + if cfg.ToolEnabled("message") { toolsRegistry.Register(message.NewMessageTool()) } // // Spawn tool - // if cfg.Tools.Spawn.Enabled { + // if cfg.ToolEnabled("spawn") { // // Note: Spawn tool is registered separately in agent loop // } diff --git a/pkg/tools/web_search/config.go b/pkg/tools/web_search/config.go new file mode 100644 index 000000000..4a32b725a --- /dev/null +++ b/pkg/tools/web_search/config.go @@ -0,0 +1,93 @@ +package web_search + +import ( + "github.com/sipeed/picoclaw/pkg/config" +) + +type WebToolsConfig struct { + Brave BraveConfig + Tavily TavilyConfig + DuckDuckGo DuckDuckGoConfig + Perplexity PerplexityConfig + Proxy string + Enabled bool +} + +type BraveConfig struct { + Enabled bool + APIKey string + MaxResults int +} + +type TavilyConfig struct { + Enabled bool + APIKey string + BaseURL string + MaxResults int +} + +type DuckDuckGoConfig struct { + Enabled bool + MaxResults int +} + +type PerplexityConfig struct { + Enabled bool + APIKey string + MaxResults int +} + +func GetWebToolsConfig(cfg *config.Config) WebToolsConfig { + tc := cfg.GetTool("web") + if tc == nil || !tc.Enabled { + return WebToolsConfig{} + } + extra := tc.Extra + if extra == nil { + return WebToolsConfig{Enabled: true} + } + webCfg := WebToolsConfig{Enabled: true} + + if v, ok := extra["brave"]; ok { + if m, ok := v.(map[string]any); ok { + webCfg.Brave = BraveConfig{ + Enabled: config.GetBoolOrDefault(m, "enabled", false), + APIKey: config.GetStringOrDefault(m, "api_key", ""), + MaxResults: config.GetIntOrDefault(m, "max_results", 5), + } + } + } + if v, ok := extra["tavily"]; ok { + if m, ok := v.(map[string]any); ok { + webCfg.Tavily = TavilyConfig{ + Enabled: config.GetBoolOrDefault(m, "enabled", false), + APIKey: config.GetStringOrDefault(m, "api_key", ""), + MaxResults: config.GetIntOrDefault(m, "max_results", 5), + BaseURL: config.GetStringOrDefault(m, "base_url", ""), + } + } + } + if v, ok := extra["duckduckgo"]; ok { + if m, ok := v.(map[string]any); ok { + webCfg.DuckDuckGo = DuckDuckGoConfig{ + Enabled: config.GetBoolOrDefault(m, "enabled", false), + MaxResults: config.GetIntOrDefault(m, "max_results", 5), + } + } + } + if v, ok := extra["perplexity"]; ok { + if m, ok := v.(map[string]any); ok { + webCfg.Perplexity = PerplexityConfig{ + Enabled: config.GetBoolOrDefault(m, "enabled", false), + APIKey: config.GetStringOrDefault(m, "api_key", ""), + MaxResults: config.GetIntOrDefault(m, "max_results", 5), + } + } + } + if v, ok := extra["proxy"]; ok { + if s, ok := v.(string); ok { + webCfg.Proxy = s + } + } + return webCfg +}