This commit is contained in:
lxowalle 2026-03-02 21:16:12 +08:00
parent cadf39c9ef
commit 9cf640daaf
15 changed files with 625 additions and 249 deletions

View file

@ -71,7 +71,8 @@ func gatewayCmd(debug bool) error {
}) })
// Setup cron tool and service // 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( cronService := setupCronTool(
agentLoop, agentLoop,
msgBus, msgBus,
@ -233,8 +234,9 @@ func setupCronTool(
cronService := cron.NewCronService(cronStorePath, nil) cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool // Create and register CronTool
if cfg.Tools.Cron.Enabled { cronToolCfg := cron_tool.GetCronConfig(cfg)
cronTool := cron_tool.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) if cronToolCfg.Enabled {
cronTool := cron_tool.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg.GetTool("cron"))
agentLoop.RegisterTool(cronTool) agentLoop.RegisterTool(cronTool)
// Set the onJob handler // Set the onJob handler

View file

@ -12,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/tools/find_skills"
"github.com/sipeed/picoclaw/pkg/utils" "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) fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ skillsCfg := find_skills.GetSkillsConfig(cfg)
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, registryMgr := skills.NewRegistryManagerFromConfig(skillsCfg)
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
})
registry := registryMgr.GetRegistry(registryName) registry := registryMgr.GetRegistry(registryName)
if registry == nil { if registry == nil {

View file

@ -94,7 +94,7 @@ func registerSharedTools(
} }
// Message tool // Message tool
if cfg.Tools.Message.Enabled { if cfg.ToolEnabled("message") {
messageTool := message.NewMessageTool() messageTool := message.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error { messageTool.SetSendCallback(func(channel, chatID, content string) error {
msgBus.PublishOutbound(bus.OutboundMessage{ msgBus.PublishOutbound(bus.OutboundMessage{
@ -108,7 +108,7 @@ func registerSharedTools(
} }
// Spawn tool with allowlist checker // Spawn tool with allowlist checker
if cfg.Tools.Spawn.Enabled { if cfg.ToolEnabled("spawn") {
subagentManager := subagent.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) subagentManager := subagent.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
spawnTool := subagent.NewSpawnTool(subagentManager) spawnTool := subagent.NewSpawnTool(subagentManager)

View file

@ -55,7 +55,8 @@ type Config struct {
Providers ProvidersConfig `json:"providers,omitempty"` Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"` Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"` Tools ToolsConfig `json:"tools,omitempty"`
ToolList []ToolConfig `json:"tool_list"`
Heartbeat HeartbeatConfig `json:"heartbeat"` Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"` Devices DevicesConfig `json:"devices"`
} }
@ -468,7 +469,9 @@ type CronToolConfig struct {
} }
type ToolConfig 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 { type ExecConfig struct {
@ -564,6 +567,15 @@ func LoadConfig(path string) (*Config, error) {
cfg.ModelList = nil 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 { if err := json.Unmarshal(data, cfg); err != nil {
return nil, err return nil, err
} }
@ -577,6 +589,12 @@ func LoadConfig(path string) (*Config, error) {
cfg.ModelList = ConvertProvidersToModelList(cfg) 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 // Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil { if err := cfg.ValidateModelList(); err != nil {
return nil, err return nil, err
@ -595,6 +613,109 @@ func SaveConfig(path string, cfg *Config) error {
return fileutil.WriteFileAtomic(path, data, 0o600) 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 { func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace) return expandHome(c.Agents.Defaults.Workspace)
} }

View file

@ -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) { func TestSaveConfig_FilePermissions(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("file permission bits are not enforced on 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 // TestDefaultConfig_DMScope verifies the default dm_scope value
func TestDefaultConfig_DMScope(t *testing.T) { func TestDefaultConfig_DMScope(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()

View file

@ -275,85 +275,117 @@ func DefaultConfig() *Config {
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 18790, Port: 18790,
}, },
Tools: ToolsConfig{ ToolList: []ToolConfig{
Web: WebToolsConfig{ {
Proxy: "", Name: "read-file",
Brave: BraveConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
MaxResults: 5,
},
Perplexity: PerplexityConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
},
Cron: CronToolConfig{
Enabled: true,
ExecTimeoutMinutes: 5,
},
// File tools - each individually configurable
ReadFile: ToolConfig{
Enabled: true, Enabled: true,
}, },
WriteFile: ToolConfig{ {
Name: "write-file",
Enabled: true, Enabled: true,
}, },
EditFile: ToolConfig{ {
Name: "edit-file",
Enabled: false, Enabled: false,
}, },
AppendFile: ToolConfig{ {
Name: "append-file",
Enabled: false, Enabled: false,
}, },
ListDir: ToolConfig{ {
Name: "list-dir",
Enabled: false, Enabled: false,
}, },
// Exec tool {
Exec: ExecConfig{ Name: "exec",
Enabled: true, Enabled: true,
EnableDenyPatterns: true, Extra: map[string]any{
"enable_deny_patterns": true,
"custom_deny_patterns": []string{},
}, },
// Skills tools },
FindSkills: ToolConfig{ {
Name: "find-skills",
Enabled: true, Enabled: true,
}, },
InstallSkill: ToolConfig{ {
Name: "install-skill",
Enabled: true, Enabled: true,
}, },
// Subagent tools {
Spawn: ToolConfig{ Name: "spawn",
Enabled: true, Enabled: true,
}, },
// Message tool {
Message: ToolConfig{ Name: "message",
Enabled: true, Enabled: true,
}, },
// Hardware tools {
I2C: ToolConfig{ Name: "web",
Enabled: true,
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": "",
},
},
{
Name: "cron",
Enabled: true,
Extra: map[string]any{
"exec_timeout_minutes": 5,
},
},
{
Name: "i2c",
Enabled: false, Enabled: false,
}, },
SPI: ToolConfig{ {
Name: "spi",
Enabled: false, Enabled: false,
}, },
Skills: SkillsToolsConfig{ {
Registries: SkillsRegistriesConfig{ Name: "skills",
ClawHub: ClawHubRegistryConfig{
Enabled: true, Enabled: true,
BaseURL: "https://clawhub.ai", 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,
}, },
}, },
MaxConcurrentSearches: 2, "max_concurrent_searches": 2,
SearchCache: SearchCacheConfig{ "search_cache": map[string]any{
MaxSize: 50, "max_size": 50,
TTLSeconds: 300, "ttl_seconds": 300,
}, },
}, },
}, },
},
Tools: ToolsConfig{},
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Enabled: true, Enabled: true,
Interval: 30, Interval: 30,

88
pkg/config/parse.go Normal file
View file

@ -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
}

View file

@ -71,37 +71,37 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
var warnings []string var warnings []string
if agents, ok := getMap(data, "agents"); ok { if agents, ok := config.GetMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok { if defaults, ok := config.GetMap(agents, "defaults"); ok {
// Prefer model_name, fallback to model for backward compatibility // 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 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 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) 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 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) 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) 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 { for name, val := range providers {
pMap, ok := val.(map[string]any) pMap, ok := val.(map[string]any)
if !ok { if !ok {
continue continue
} }
apiKey, _ := getString(pMap, "api_key") apiKey, _ := config.GetString(pMap, "api_key")
apiBase, _ := getString(pMap, "api_base") apiBase, _ := config.GetString(pMap, "api_base")
if !supportedProviders[name] { if !supportedProviders[name] {
if apiKey != "" || apiBase != "" { if apiKey != "" || apiBase != "" {
@ -117,7 +117,7 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
case "openai": case "openai":
cfg.Providers.OpenAI = config.OpenAIProviderConfig{ cfg.Providers.OpenAI = config.OpenAIProviderConfig{
ProviderConfig: pc, ProviderConfig: pc,
WebSearch: getBoolOrDefault(pMap, "web_search", true), WebSearch: config.GetBoolOrDefault(pMap, "web_search", true),
} }
case "openrouter": case "openrouter":
cfg.Providers.OpenRouter = pc 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 { for name, val := range channels {
cMap, ok := val.(map[string]any) cMap, ok := val.(map[string]any)
if !ok { 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)) warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name))
continue continue
} }
enabled, _ := getBool(cMap, "enabled") enabled, _ := config.GetBool(cMap, "enabled")
allowFrom := getStringSlice(cMap, "allow_from") allowFrom := config.GetStringSlice(cMap, "allow_from")
switch name { switch name {
case "telegram": case "telegram":
cfg.Channels.Telegram.Enabled = enabled cfg.Channels.Telegram.Enabled = enabled
cfg.Channels.Telegram.AllowFrom = allowFrom 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 cfg.Channels.Telegram.Token = v
} }
case "discord": case "discord":
cfg.Channels.Discord.Enabled = enabled cfg.Channels.Discord.Enabled = enabled
cfg.Channels.Discord.AllowFrom = allowFrom 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 cfg.Channels.Discord.Token = v
} }
case "whatsapp": case "whatsapp":
cfg.Channels.WhatsApp.Enabled = enabled cfg.Channels.WhatsApp.Enabled = enabled
cfg.Channels.WhatsApp.AllowFrom = allowFrom 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 cfg.Channels.WhatsApp.BridgeURL = v
} }
case "feishu": case "feishu":
cfg.Channels.Feishu.Enabled = enabled cfg.Channels.Feishu.Enabled = enabled
cfg.Channels.Feishu.AllowFrom = allowFrom 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 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 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 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 cfg.Channels.Feishu.VerificationToken = v
} }
case "qq": case "qq":
cfg.Channels.QQ.Enabled = enabled cfg.Channels.QQ.Enabled = enabled
cfg.Channels.QQ.AllowFrom = allowFrom 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 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 cfg.Channels.QQ.AppSecret = v
} }
case "dingtalk": case "dingtalk":
cfg.Channels.DingTalk.Enabled = enabled cfg.Channels.DingTalk.Enabled = enabled
cfg.Channels.DingTalk.AllowFrom = allowFrom 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 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 cfg.Channels.DingTalk.ClientSecret = v
} }
case "maixcam": case "maixcam":
cfg.Channels.MaixCam.Enabled = enabled cfg.Channels.MaixCam.Enabled = enabled
cfg.Channels.MaixCam.AllowFrom = allowFrom 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 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) cfg.Channels.MaixCam.Port = int(v)
} }
} }
} }
} }
if gateway, ok := getMap(data, "gateway"); ok { if gateway, ok := config.GetMap(data, "gateway"); ok {
if v, ok := getString(gateway, "host"); ok { if v, ok := config.GetString(gateway, "host"); ok {
cfg.Gateway.Host = v cfg.Gateway.Host = v
} }
if v, ok := getFloat(gateway, "port"); ok { if v, ok := config.GetFloat(gateway, "port"); ok {
cfg.Gateway.Port = int(v) cfg.Gateway.Port = int(v)
} }
} }
if tools, ok := getMap(data, "tools"); ok { if tools, ok := config.GetMap(data, "tools"); ok {
if web, ok := getMap(tools, "web"); ok { if web, ok := config.GetMap(tools, "web"); ok {
// Migrate old "search" config to "brave" if api_key is present // Migrate old "search" config to "brave" if api_key is present
if search, ok := getMap(web, "search"); ok { if search, ok := config.GetMap(web, "search"); ok {
if v, ok := getString(search, "api_key"); ok { if v, ok := config.GetString(search, "api_key"); ok {
cfg.Tools.Web.Brave.APIKey = v cfg.Tools.Web.Brave.APIKey = v
if v != "" { if v != "" {
cfg.Tools.Web.Brave.Enabled = true 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.Brave.MaxResults = int(v)
cfg.Tools.Web.DuckDuckGo.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) path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
return path 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
}

32
pkg/tools/cron/config.go Normal file
View file

@ -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),
}
}

View file

@ -34,10 +34,17 @@ type CronTool struct {
// execTimeout: 0 means no timeout, >0 sets the timeout duration // execTimeout: 0 means no timeout, >0 sets the timeout duration
func NewCronTool( func NewCronTool(
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, 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 { ) *CronTool {
execTool := exec.NewExecToolWithConfig(workspace, restrict, config) execTool := exec.NewExecToolWithConfig(workspace, restrict, toolConfig)
if execTimeout > 0 {
execTool.SetTimeout(execTimeout) execTool.SetTimeout(execTimeout)
} else if toolConfig != nil {
cronCfg := ParseCronConfig(toolConfig)
if cronCfg.ExecTimeoutMinutes > 0 {
execTool.SetTimeout(time.Duration(cronCfg.ExecTimeoutMinutes) * time.Minute)
}
}
return &CronTool{ return &CronTool{
cronService: cronService, cronService: cronService,
executor: executor, executor: executor,

34
pkg/tools/exec/config.go Normal file
View file

@ -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"),
}
}

View file

@ -74,11 +74,11 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool {
return NewExecToolWithConfig(workingDir, restrict, nil) 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) denyPatterns := make([]*regexp.Regexp, 0)
if config != nil { if toolConfig != nil {
execConfig := config.Tools.Exec execConfig := ParseExecConfig(toolConfig)
enableDenyPatterns := execConfig.EnableDenyPatterns enableDenyPatterns := execConfig.EnableDenyPatterns
if enableDenyPatterns { if enableDenyPatterns {
denyPatterns = append(denyPatterns, defaultDenyPatterns...) denyPatterns = append(denyPatterns, defaultDenyPatterns...)

View file

@ -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
}

View file

@ -42,78 +42,74 @@ func NewToolRegistry(cfg *config.Config, workspace string, restrict bool) *ToolR
} }
// File tools - each with individual configuration // File tools - each with individual configuration
if cfg.Tools.ReadFile.Enabled { if cfg.ToolEnabled("read-file") {
toolsRegistry.Register(read_file.NewReadFileTool(workspace, restrict)) toolsRegistry.Register(read_file.NewReadFileTool(workspace, restrict))
} }
if cfg.Tools.WriteFile.Enabled { if cfg.ToolEnabled("write-file") {
toolsRegistry.Register(write_file.NewWriteFileTool(workspace, restrict)) toolsRegistry.Register(write_file.NewWriteFileTool(workspace, restrict))
} }
if cfg.Tools.EditFile.Enabled { if cfg.ToolEnabled("edit-file") {
toolsRegistry.Register(edit_file.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(edit_file.NewEditFileTool(workspace, restrict))
} }
if cfg.Tools.AppendFile.Enabled { if cfg.ToolEnabled("append-file") {
toolsRegistry.Register(append_file.NewAppendFileTool(workspace, restrict)) toolsRegistry.Register(append_file.NewAppendFileTool(workspace, restrict))
} }
if cfg.Tools.ListDir.Enabled { if cfg.ToolEnabled("list-dir") {
toolsRegistry.Register(list_dir.NewListDirTool(workspace, restrict)) toolsRegistry.Register(list_dir.NewListDirTool(workspace, restrict))
} }
// Exec tool // Exec tool
if cfg.Tools.Exec.Enabled { if cfg.ToolEnabled("exec") {
toolsRegistry.Register(exec.NewExecToolWithConfig(workspace, restrict, cfg)) toolsRegistry.Register(exec.NewExecToolWithConfig(workspace, restrict, cfg.GetTool("exec")))
} }
// Web tools // Web tools
webCfg := web_search.GetWebToolsConfig(cfg)
if searchTool := web_search.NewWebSearchTool(web_search.WebSearchToolOptions{ if searchTool := web_search.NewWebSearchTool(web_search.WebSearchToolOptions{
BraveAPIKey: cfg.Tools.Web.Brave.APIKey, BraveAPIKey: webCfg.Brave.APIKey,
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveMaxResults: webCfg.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled, BraveEnabled: webCfg.Brave.Enabled,
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, TavilyAPIKey: webCfg.Tavily.APIKey,
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, TavilyBaseURL: webCfg.Tavily.BaseURL,
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, TavilyMaxResults: webCfg.Tavily.MaxResults,
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, TavilyEnabled: webCfg.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoMaxResults: webCfg.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, DuckDuckGoEnabled: webCfg.DuckDuckGo.Enabled,
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityAPIKey: webCfg.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityMaxResults: webCfg.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, PerplexityEnabled: webCfg.Perplexity.Enabled,
Proxy: cfg.Tools.Web.Proxy, Proxy: webCfg.Proxy,
}); searchTool != nil { }); searchTool != nil {
toolsRegistry.Register(searchTool) 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 // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
if cfg.Tools.I2C.Enabled { if cfg.ToolEnabled("i2c") {
toolsRegistry.Register(i2c.NewI2CTool()) toolsRegistry.Register(i2c.NewI2CTool())
} }
if cfg.Tools.SPI.Enabled { if cfg.ToolEnabled("spi") {
toolsRegistry.Register(spi.NewSPITool()) toolsRegistry.Register(spi.NewSPITool())
} }
// Skill discovery and installation tools // Skill discovery and installation tools
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ skillsCfg := find_skills.GetSkillsConfig(cfg)
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, registryMgr := skills.NewRegistryManagerFromConfig(skillsCfg)
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), searchCache := find_skills.GetSearchCache(cfg)
}) if cfg.ToolEnabled("find-skills") {
searchCache := skills.NewSearchCache(
cfg.Tools.Skills.SearchCache.MaxSize,
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
)
if cfg.Tools.FindSkills.Enabled {
toolsRegistry.Register(find_skills.NewFindSkillsTool(registryMgr, searchCache)) toolsRegistry.Register(find_skills.NewFindSkillsTool(registryMgr, searchCache))
} }
if cfg.Tools.InstallSkill.Enabled { if cfg.ToolEnabled("install-skill") {
toolsRegistry.Register(install_skill.NewInstallSkillTool(registryMgr, workspace)) toolsRegistry.Register(install_skill.NewInstallSkillTool(registryMgr, workspace))
} }
// Message tool // Message tool
if cfg.Tools.Message.Enabled { if cfg.ToolEnabled("message") {
toolsRegistry.Register(message.NewMessageTool()) toolsRegistry.Register(message.NewMessageTool())
} }
// // Spawn tool // // Spawn tool
// if cfg.Tools.Spawn.Enabled { // if cfg.ToolEnabled("spawn") {
// // Note: Spawn tool is registered separately in agent loop // // Note: Spawn tool is registered separately in agent loop
// } // }

View file

@ -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
}