Merge branch 'main' into d3
This commit is contained in:
commit
ff3a7d19d6
30 changed files with 915 additions and 189 deletions
4
LICENSE
4
LICENSE
|
|
@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
|
||||||
|
|
|
||||||
119
README.md
119
README.md
|
|
@ -216,7 +216,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -265,6 +265,16 @@ picoclaw onboard
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +287,12 @@ picoclaw onboard
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional):
|
||||||
|
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
|
||||||
|
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
|
||||||
|
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
|
||||||
|
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
|
||||||
|
* DuckDuckGo - Built-in fallback (no API key required)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -1241,6 +1256,16 @@ picoclaw agent -m "Hello"
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
|
@ -1298,10 +1323,69 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||||
|
|
||||||
To enable web search:
|
#### Search Provider Priority
|
||||||
|
|
||||||
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
PicoClaw automatically selects the best available search provider in this order:
|
||||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
|
||||||
|
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
|
||||||
|
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
|
||||||
|
4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
|
||||||
|
|
||||||
|
#### Web Search Configuration Options
|
||||||
|
|
||||||
|
**Option 1 (Best Results)**: Perplexity AI Search
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"searxng": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "http://your-server:8888",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits of SearXNG:
|
||||||
|
- **Zero cost**: No API fees or rate limits
|
||||||
|
- **Privacy-focused**: Self-hosted, no tracking
|
||||||
|
- **Aggregate results**: Queries 70+ search engines simultaneously
|
||||||
|
- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
|
||||||
|
- **No API key needed**: Just deploy and configure the base URL
|
||||||
|
|
||||||
|
**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
|
||||||
|
|
||||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
|
|
||||||
|
|
@ -1317,6 +1401,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1335,10 +1429,11 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
|
|
||||||
## 📝 API Key Comparison
|
## 📝 API Key Comparison
|
||||||
|
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------- | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,11 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
s.showMessage("Test OK", resp.Status)
|
s.showMessage("Test OK", resp.Status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
s.showMessage(
|
s.showMessage(
|
||||||
"Test failed",
|
"Test failed",
|
||||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -366,22 +366,25 @@ func setupCronTool(
|
||||||
) *cron.CronService {
|
) *cron.CronService {
|
||||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||||
|
|
||||||
// Create cron service
|
// Create and register CronTool if enabled
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
var cronTool *tools.CronTool
|
||||||
|
if cfg.Tools.IsToolEnabled("cron") {
|
||||||
|
var err error
|
||||||
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create and register CronTool
|
agentLoop.RegisterTool(cronTool)
|
||||||
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("Critical error during CronTool initialization: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
// Set onJob handler
|
||||||
|
if cronTool != nil {
|
||||||
// Set the onJob handler
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
return result, nil
|
||||||
return result, nil
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
return cronService
|
return cronService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,21 @@ var (
|
||||||
goVersion string
|
goVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
|
func GetPicoclawHome() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,27 @@ func TestGetConfigPath(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join("/custom/picoclaw", "config.json")
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := "/custom/config.json"
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
oldVersion, oldGit := version, gitCommit
|
oldVersion, oldGit := version, gitCommit
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
if len(args) != 2 {
|
if len(args) != 1 {
|
||||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
registry string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no registry, one arg",
|
||||||
|
args: []string{"sipeed/picoclaw-skills/weather"},
|
||||||
|
registry: "",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, one arg",
|
||||||
|
args: []string{"weather-skill"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
|
if tt.registry != "" {
|
||||||
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Args(cmd, tt.args)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, tt.errorMsg, err.Error())
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,24 +232,46 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
"allow_read_paths": null,
|
||||||
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"max_results": 0
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"proxy": ""
|
"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
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
|
|
@ -318,19 +340,75 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": false,
|
"enabled": true,
|
||||||
"custom_deny_patterns": []
|
"enable_deny_patterns": true,
|
||||||
|
"custom_deny_patterns": null,
|
||||||
|
"custom_allow_patterns": null
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"enabled": true,
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
"search_path": "/api/v1/search",
|
"auth_token": "",
|
||||||
"skills_path": "/api/v1/skills",
|
"search_path": "",
|
||||||
"download_path": "/api/v1/download"
|
"skills_path": "",
|
||||||
|
"download_path": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"max_zip_size": 0,
|
||||||
|
"max_response_size": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"max_concurrent_searches": 2,
|
||||||
|
"search_cache": {
|
||||||
|
"max_size": 50,
|
||||||
|
"ttl_seconds": 300
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"media_cleanup": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_age_minutes": 30,
|
||||||
|
"interval_minutes": 5
|
||||||
|
},
|
||||||
|
"append_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"edit_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"find_skills": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"i2c": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"install_skill": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"list_dir": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"read_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spawn": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spi": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"subagent": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"web_fetch": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"write_file": {
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||||
|
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||||
|
|
@ -194,6 +195,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
|
"auth_token": "",
|
||||||
"search_path": "/api/v1/search",
|
"search_path": "/api/v1/search",
|
||||||
"skills_path": "/api/v1/skills",
|
"skills_path": "/api/v1/skills",
|
||||||
"download_path": "/api/v1/download"
|
"download_path": "/api/v1/download"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ type ContextBuilder struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -60,17 +60,30 @@ func NewAgentInstance(
|
||||||
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
|
||||||
}
|
|
||||||
toolsRegistry.Register(execTool)
|
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
@ -195,12 +208,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
}
|
}
|
||||||
|
// Use the configured default workspace (respects PICOCLAW_HOME)
|
||||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||||
return expandHome(defaults.Workspace)
|
return expandHome(defaults.Workspace)
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
// For named agents without explicit workspace, use default workspace with agent ID suffix
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentModel resolves the primary model for an agent.
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
|
|
||||||
|
|
@ -108,76 +108,106 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
// Web tools
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||||
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
})
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
if err != nil {
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||||
} else if searchTool != nil {
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
agent.Tools.Register(searchTool)
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
||||||
|
} else if searchTool != nil {
|
||||||
|
agent.Tools.Register(searchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
||||||
if err != nil {
|
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
if err != nil {
|
||||||
} else {
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
agent.Tools.Register(fetchTool)
|
} else {
|
||||||
|
agent.Tools.Register(fetchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
if cfg.Tools.IsToolEnabled("i2c") {
|
||||||
agent.Tools.Register(tools.NewSPITool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("spi") {
|
||||||
|
agent.Tools.Register(tools.NewSPITool())
|
||||||
|
}
|
||||||
|
|
||||||
// Message tool
|
// Message tool
|
||||||
messageTool := tools.NewMessageTool()
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool := tools.NewMessageTool()
|
||||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
defer pubCancel()
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
defer pubCancel()
|
||||||
Channel: channel,
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
ChatID: chatID,
|
Channel: channel,
|
||||||
Content: content,
|
ChatID: chatID,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
agent.Tools.Register(messageTool)
|
||||||
agent.Tools.Register(messageTool)
|
}
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
})
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
searchCache := skills.NewSearchCache(
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
cfg.Tools.Skills.SearchCache.MaxSize,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
)
|
})
|
||||||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
|
||||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
if find_skills_enable {
|
||||||
|
searchCache := skills.NewSearchCache(
|
||||||
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||||
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
|
}
|
||||||
|
|
||||||
|
if install_skills_enable {
|
||||||
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
if cfg.Tools.IsToolEnabled("spawn") {
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
||||||
currentAgentID := agentID
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
currentAgentID := agentID
|
||||||
})
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
agent.Tools.Register(spawnTool)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
|
})
|
||||||
|
agent.Tools.Register(spawnTool)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +215,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
al.running.Store(true)
|
al.running.Store(true)
|
||||||
|
|
||||||
// Initialize MCP servers for all agents
|
// Initialize MCP servers for all agents
|
||||||
if al.cfg.Tools.MCP.Enabled {
|
if al.cfg.Tools.IsToolEnabled("mcp") {
|
||||||
mcpManager := mcp.NewManager()
|
mcpManager := mcp.NewManager()
|
||||||
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
|
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
|
||||||
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
|
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
|
||||||
|
|
@ -227,6 +257,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
agent.Tools.Register(mcpTool)
|
agent.Tools.Register(mcpTool)
|
||||||
totalRegistrations++
|
totalRegistrations++
|
||||||
|
|
|
||||||
|
|
@ -227,16 +227,11 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := config.DefaultConfig()
|
||||||
Agents: config.AgentsConfig{
|
cfg.Agents.Defaults.Workspace = tmpDir
|
||||||
Defaults: config.AgentDefaults{
|
cfg.Agents.Defaults.Model = "test-model"
|
||||||
Workspace: tmpDir,
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
Model: "test-model",
|
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,10 @@ func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
return nil, fmt.Errorf("pending")
|
return nil, fmt.Errorf("pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp struct {
|
var tokenResp struct {
|
||||||
AuthorizationCode string `json:"authorization_code"`
|
AuthorizationCode string `json:"authorization_code"`
|
||||||
|
|
@ -401,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token refresh response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +506,10 @@ func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token exchange response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFilePath() string {
|
func authFilePath() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return filepath.Join(home, "auth.json")
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -654,7 +654,10 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
|
||||||
|
}
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -793,7 +793,10 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode == http.StatusTooManyRequests:
|
case resp.StatusCode == http.StatusTooManyRequests:
|
||||||
return fmt.Errorf("response_url rate limited (%d): %s: %w",
|
return fmt.Errorf("response_url rate limited (%d): %s: %w",
|
||||||
|
|
|
||||||
|
|
@ -321,8 +321,17 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)))
|
if readErr != nil {
|
||||||
|
return "", channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading wecom upload error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "", channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("wecom upload error: %s", string(respBody)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -371,8 +380,17 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)))
|
if readErr != nil {
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading wecom_app error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("wecom_app API error: %s", string(respBody)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
|
|
@ -453,8 +453,17 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(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, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,10 @@ type GatewayConfig struct {
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||||
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
|
|
@ -550,6 +554,12 @@ type PerplexityConfig struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
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 {
|
type GLMSearchConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
||||||
|
|
@ -561,11 +571,13 @@ type GLMSearchConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
Brave BraveConfig `json:"brave"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
Tavily TavilyConfig `json:"tavily"`
|
Brave BraveConfig ` json:"brave"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
Tavily TavilyConfig ` json:"tavily"`
|
||||||
Perplexity PerplexityConfig `json:"perplexity"`
|
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
||||||
GLMSearch GLMSearchConfig `json:"glm_search"`
|
Perplexity PerplexityConfig ` json:"perplexity"`
|
||||||
|
SearXNG SearXNGConfig ` json:"searxng"`
|
||||||
|
GLMSearch GLMSearchConfig ` json:"glm_search"`
|
||||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
// 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.
|
// 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"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
|
|
@ -573,19 +585,28 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||||
|
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||||
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
||||||
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
|
||||||
|
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillsToolsConfig struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
|
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||||
|
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||||
|
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaCleanupConfig struct {
|
type MediaCleanupConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
|
ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
|
||||||
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
|
MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
|
||||||
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
|
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
|
|
@ -597,12 +618,19 @@ type ToolsConfig struct {
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
MCP MCPConfig `json:"mcp"`
|
MCP MCPConfig `json:"mcp"`
|
||||||
}
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
type SkillsToolsConfig struct {
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
Registries SkillsRegistriesConfig `json:"registries"`
|
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||||
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
SearchCache SearchCacheConfig `json:"search_cache"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
|
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
|
ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
|
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchCacheConfig struct {
|
type SearchCacheConfig struct {
|
||||||
|
|
@ -648,8 +676,7 @@ type MCPServerConfig struct {
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
// MCPConfig defines configuration for all MCP servers
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
// Enabled globally enables/disables MCP integration
|
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"`
|
|
||||||
// Servers is a map of server name to server configuration
|
// Servers is a map of server name to server configuration
|
||||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -835,3 +862,48 @@ func (c *Config) ValidateModelList() error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "web":
|
||||||
|
return t.Web.Enabled
|
||||||
|
case "cron":
|
||||||
|
return t.Cron.Enabled
|
||||||
|
case "exec":
|
||||||
|
return t.Exec.Enabled
|
||||||
|
case "skills":
|
||||||
|
return t.Skills.Enabled
|
||||||
|
case "media_cleanup":
|
||||||
|
return t.MediaCleanup.Enabled
|
||||||
|
case "append_file":
|
||||||
|
return t.AppendFile.Enabled
|
||||||
|
case "edit_file":
|
||||||
|
return t.EditFile.Enabled
|
||||||
|
case "find_skills":
|
||||||
|
return t.FindSkills.Enabled
|
||||||
|
case "i2c":
|
||||||
|
return t.I2C.Enabled
|
||||||
|
case "install_skill":
|
||||||
|
return t.InstallSkill.Enabled
|
||||||
|
case "list_dir":
|
||||||
|
return t.ListDir.Enabled
|
||||||
|
case "message":
|
||||||
|
return t.Message.Enabled
|
||||||
|
case "read_file":
|
||||||
|
return t.ReadFile.Enabled
|
||||||
|
case "spawn":
|
||||||
|
return t.Spawn.Enabled
|
||||||
|
case "spi":
|
||||||
|
return t.SPI.Enabled
|
||||||
|
case "subagent":
|
||||||
|
return t.Subagent.Enabled
|
||||||
|
case "web_fetch":
|
||||||
|
return t.WebFetch.Enabled
|
||||||
|
case "write_file":
|
||||||
|
return t.WriteFile.Enabled
|
||||||
|
case "mcp":
|
||||||
|
return t.MCP.Enabled
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -336,11 +336,16 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
MediaCleanup: MediaCleanupConfig{
|
MediaCleanup: MediaCleanupConfig{
|
||||||
Enabled: true,
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
MaxAge: 30,
|
MaxAge: 30,
|
||||||
Interval: 5,
|
Interval: 5,
|
||||||
},
|
},
|
||||||
Web: WebToolsConfig{
|
Web: WebToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Proxy: "",
|
Proxy: "",
|
||||||
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
|
|
@ -357,6 +362,11 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
|
SearXNG: SearXNGConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BaseURL: "",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
GLMSearch: GLMSearchConfig{
|
GLMSearch: GLMSearchConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
|
@ -366,12 +376,21 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
ExecTimeoutMinutes: 5,
|
ExecTimeoutMinutes: 5,
|
||||||
},
|
},
|
||||||
Exec: ExecConfig{
|
Exec: ExecConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
EnableDenyPatterns: true,
|
EnableDenyPatterns: true,
|
||||||
},
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Registries: SkillsRegistriesConfig{
|
Registries: SkillsRegistriesConfig{
|
||||||
ClawHub: ClawHubRegistryConfig{
|
ClawHub: ClawHubRegistryConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -385,9 +404,50 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MCP: MCPConfig{
|
MCP: MCPConfig{
|
||||||
Enabled: false,
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
Servers: map[string]MCPServerConfig{},
|
Servers: map[string]MCPServerConfig{},
|
||||||
},
|
},
|
||||||
|
AppendFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
EditFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
FindSkills: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
I2C: ToolConfig{
|
||||||
|
Enabled: false, // Hardware tool - Linux only
|
||||||
|
},
|
||||||
|
InstallSkill: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ListDir: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
Message: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ReadFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
Spawn: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
SPI: ToolConfig{
|
||||||
|
Enabled: false, // Hardware tool - Linux only
|
||||||
|
},
|
||||||
|
Subagent: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WebFetch: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WriteFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Heartbeat: HeartbeatConfig{
|
Heartbeat: HeartbeatConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,9 @@ func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) {
|
||||||
mgr := NewManager()
|
mgr := NewManager()
|
||||||
|
|
||||||
mcpCfg := config.MCPConfig{
|
mcpCfg := config.MCPConfig{
|
||||||
Enabled: true,
|
ToolConfig: config.ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Servers: map[string]config.MCPServerConfig{
|
Servers: map[string]config.MCPServerConfig{
|
||||||
"test-server": {
|
"test-server": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -228,12 +230,20 @@ func TestNewManager_InitialState(t *testing.T) {
|
||||||
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
|
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
|
||||||
mgr := NewManager()
|
mgr := NewManager()
|
||||||
|
|
||||||
err := mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: false}, "/tmp")
|
err := mgr.LoadFromMCPConfig(
|
||||||
|
context.Background(),
|
||||||
|
config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: false}},
|
||||||
|
"/tmp",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected nil error when MCP disabled, got: %v", err)
|
t.Fatalf("expected nil error when MCP disabled, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: true}, "/tmp")
|
err = mgr.LoadFromMCPConfig(
|
||||||
|
context.Background(),
|
||||||
|
config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: true}},
|
||||||
|
"/tmp",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected nil error when no servers configured, got: %v", err)
|
t.Fatalf("expected nil error when no servers configured, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -640,7 +640,10 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading loadCodeAssist response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
|
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -681,7 +684,10 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading fetchAvailableModels response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"fetchAvailableModels failed (HTTP %d): %s",
|
"fetchAvailableModels failed (HTTP %d): %s",
|
||||||
|
|
|
||||||
|
|
@ -323,12 +323,14 @@ func serializeMessages(messages []Message) []any {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, mediaURL := range m.Media {
|
for _, mediaURL := range m.Media {
|
||||||
parts = append(parts, map[string]any{
|
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||||
"type": "image_url",
|
parts = append(parts, map[string]any{
|
||||||
"image_url": map[string]any{
|
"type": "image_url",
|
||||||
"url": mediaURL,
|
"image_url": map[string]any{
|
||||||
},
|
"url": mediaURL,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := map[string]any{
|
msg := map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -259,15 +259,7 @@ func (c *ClawHubRegistry) DownloadAndInstall(
|
||||||
}
|
}
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
tmpPath, err := c.downloadToTempFileWithRetry(ctx, u.String())
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
if c.authToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath, err := utils.DownloadToFile(ctx, c.client, req, int64(c.maxZipSize))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download failed: %w", err)
|
return nil, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -284,17 +276,12 @@ func (c *ClawHubRegistry) DownloadAndInstall(
|
||||||
// --- HTTP helper ---
|
// --- HTTP helper ---
|
||||||
|
|
||||||
func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) {
|
func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
req, err := c.newGetRequest(ctx, urlStr, "application/json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
resp, err := utils.DoRequestWithRetry(c.client, req)
|
||||||
if c.authToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -312,3 +299,64 @@ func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, err
|
||||||
|
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ClawHubRegistry) newGetRequest(ctx context.Context, urlStr, accept string) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", accept)
|
||||||
|
if c.authToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlStr string) (string, error) {
|
||||||
|
req, err := c.newGetRequest(ctx, urlStr, "application/zip")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := utils.DoRequestWithRetry(c.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errBody := make([]byte, 512)
|
||||||
|
n, _ := io.ReadFull(resp.Body, errBody)
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n]))
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "picoclaw-dl-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := io.LimitReader(resp.Body, int64(c.maxZipSize)+1)
|
||||||
|
written, err := io.Copy(tmpFile, src)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", fmt.Errorf("download write failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if written > int64(c.maxZipSize) {
|
||||||
|
cleanup()
|
||||||
|
return "", fmt.Errorf("download too large: %d bytes (max %d)", written, c.maxZipSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("failed to close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpPath, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,39 @@ func TestClawHubRegistrySearch(t *testing.T) {
|
||||||
assert.Equal(t, "clawhub", results[0].RegistryName)
|
assert.Equal(t, "clawhub", results[0].RegistryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClawHubRegistrySearchRetries429(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", "0")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := "github"
|
||||||
|
name := "GitHub Integration"
|
||||||
|
summary := "Interact with GitHub repos"
|
||||||
|
version := "1.0.0"
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(clawhubSearchResponse{
|
||||||
|
Results: []clawhubSearchResult{
|
||||||
|
{Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
reg := newTestRegistry(srv.URL, "")
|
||||||
|
results, err := reg.Search(context.Background(), "github", 5)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
assert.Equal(t, 2, attempts)
|
||||||
|
assert.Equal(t, "github", results[0].Slug)
|
||||||
|
}
|
||||||
|
|
||||||
func TestClawHubRegistryGetSkillMeta(t *testing.T) {
|
func TestClawHubRegistryGetSkillMeta(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "/api/v1/skills/github", r.URL.Path)
|
assert.Equal(t, "/api/v1/skills/github", r.URL.Path)
|
||||||
|
|
@ -137,6 +170,54 @@ func TestClawHubRegistryDownloadAndInstall(t *testing.T) {
|
||||||
assert.Contains(t, string(readmeContent), "# Test Skill")
|
assert.Contains(t, string(readmeContent), "# Test Skill")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClawHubRegistryDownloadAndInstallRetries429(t *testing.T) {
|
||||||
|
zipBuf := createTestZip(t, map[string]string{
|
||||||
|
"SKILL.md": "---\nname: retry-skill\ndescription: A test\n---\nHello skill",
|
||||||
|
})
|
||||||
|
|
||||||
|
downloadAttempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/skills/retry-skill":
|
||||||
|
json.NewEncoder(w).Encode(clawhubSkillResponse{
|
||||||
|
Slug: "retry-skill",
|
||||||
|
DisplayName: "Retry Skill",
|
||||||
|
Summary: "A retry test skill",
|
||||||
|
LatestVersion: &clawhubVersionInfo{Version: "1.0.0"},
|
||||||
|
})
|
||||||
|
case "/api/v1/download":
|
||||||
|
downloadAttempts++
|
||||||
|
if downloadAttempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", "0")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, "retry-skill", r.URL.Query().Get("slug"))
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Write(zipBuf)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
targetDir := filepath.Join(tmpDir, "retry-skill")
|
||||||
|
|
||||||
|
reg := newTestRegistry(srv.URL, "")
|
||||||
|
result, err := reg.DownloadAndInstall(context.Background(), "retry-skill", "", targetDir)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, "1.0.0", result.Version)
|
||||||
|
assert.Equal(t, 2, downloadAttempts)
|
||||||
|
|
||||||
|
skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(skillContent), "Hello skill")
|
||||||
|
}
|
||||||
|
|
||||||
func TestClawHubRegistryAuthToken(t *testing.T) {
|
func TestClawHubRegistryAuthToken(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,68 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearXNGSearchProvider struct {
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general",
|
||||||
|
strings.TrimSuffix(p.baseURL, "/"),
|
||||||
|
url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results to requested count
|
||||||
|
if len(result.Results) > count {
|
||||||
|
result.Results = result.Results[:count]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results in standard PicoClaw format
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Results for: %s (via SearXNG)\n", query))
|
||||||
|
for i, r := range result.Results {
|
||||||
|
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, r.Title))
|
||||||
|
b.WriteString(fmt.Sprintf(" %s\n", r.URL))
|
||||||
|
if r.Content != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" %s\n", r.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
type GLMSearchProvider struct {
|
type GLMSearchProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
|
|
@ -495,6 +557,9 @@ type WebSearchToolOptions struct {
|
||||||
PerplexityAPIKey string
|
PerplexityAPIKey string
|
||||||
PerplexityMaxResults int
|
PerplexityMaxResults int
|
||||||
PerplexityEnabled bool
|
PerplexityEnabled bool
|
||||||
|
SearXNGBaseURL string
|
||||||
|
SearXNGMaxResults int
|
||||||
|
SearXNGEnabled bool
|
||||||
GLMSearchAPIKey string
|
GLMSearchAPIKey string
|
||||||
GLMSearchBaseURL string
|
GLMSearchBaseURL string
|
||||||
GLMSearchEngine string
|
GLMSearchEngine string
|
||||||
|
|
@ -507,7 +572,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
var provider SearchProvider
|
var provider SearchProvider
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
|
|
||||||
// Priority: Perplexity > Brave > Tavily > DuckDuckGo > GLM Search
|
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
||||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
||||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -526,6 +591,11 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
if opts.BraveMaxResults > 0 {
|
if opts.BraveMaxResults > 0 {
|
||||||
maxResults = opts.BraveMaxResults
|
maxResults = opts.BraveMaxResults
|
||||||
}
|
}
|
||||||
|
} else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" {
|
||||||
|
provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL}
|
||||||
|
if opts.SearXNGMaxResults > 0 {
|
||||||
|
maxResults = opts.SearXNGMaxResults
|
||||||
|
}
|
||||||
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue