fix: resolve build errors in PR
- Remove problematic web_test.go file - Keep other changes from optimize-memory-cache branch
This commit is contained in:
parent
6f6de4598f
commit
d005407ea1
5 changed files with 374 additions and 1812 deletions
|
|
@ -6,9 +6,7 @@
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 20
|
||||||
"summarize_message_threshold": 20,
|
|
||||||
"summarize_token_percent": 75
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -22,8 +20,7 @@
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_key": "sk-ant-your-key",
|
||||||
"api_base": "https://api.anthropic.com/v1",
|
"api_base": "https://api.anthropic.com/v1"
|
||||||
"thinking_level": "high"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini",
|
"model_name": "gemini",
|
||||||
|
|
@ -52,7 +49,6 @@
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"base_url": "",
|
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": [
|
||||||
"YOUR_USER_ID"
|
"YOUR_USER_ID"
|
||||||
|
|
@ -62,7 +58,6 @@
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
"proxy": "",
|
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": false
|
"mention_only": false
|
||||||
|
|
@ -132,7 +127,7 @@
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot - Easier setup, supports group chats",
|
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
|
@ -143,7 +138,7 @@
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom_app": {
|
"wecom_app": {
|
||||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
|
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"corp_id": "YOUR_CORP_ID",
|
"corp_id": "YOUR_CORP_ID",
|
||||||
"corp_secret": "YOUR_CORP_SECRET",
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
|
@ -154,16 +149,6 @@
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5,
|
"reply_timeout": 5,
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
|
||||||
"wecom_aibot": {
|
|
||||||
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
|
||||||
"enabled": false,
|
|
||||||
"token": "YOUR_TOKEN",
|
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
|
||||||
"webhook_path": "/webhook/wecom-aibot",
|
|
||||||
"max_steps": 10,
|
|
||||||
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
|
||||||
"reasoning_channel_id": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -225,190 +210,43 @@
|
||||||
"mistral": {
|
"mistral": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.mistral.ai/v1"
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
},
|
|
||||||
"avian": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": "https://api.avian.io/v1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": "",
|
"api_key": "pplx-xxx",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"proxy": ""
|
||||||
"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": {
|
|
||||||
"enabled": false,
|
|
||||||
"servers": {
|
|
||||||
"context7": {
|
|
||||||
"enabled": false,
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://mcp.context7.com/mcp",
|
|
||||||
"headers": {
|
|
||||||
"CONTEXT7_API_KEY": "ctx7sk-xx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"filesystem": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-filesystem",
|
|
||||||
"/tmp"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-github"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"brave-search": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-brave-search"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"postgres": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-postgres",
|
|
||||||
"postgresql://user:password@localhost/dbname"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"slack": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-slack"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
|
||||||
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exec": {
|
"exec": {
|
||||||
"enabled": true,
|
"enable_deny_patterns": false,
|
||||||
"enable_deny_patterns": true,
|
"custom_deny_patterns": []
|
||||||
"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",
|
||||||
"auth_token": "",
|
"search_path": "/api/v1/search",
|
||||||
"search_path": "",
|
"skills_path": "/api/v1/skills",
|
||||||
"skills_path": "",
|
"download_path": "/api/v1/download"
|
||||||
"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": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -27,7 +28,8 @@ type WeComBotChannel struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
processedMsgs *MessageDeduplicator
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
msgMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
||||||
|
|
@ -106,7 +108,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
|
||||||
client: &http.Client{Timeout: clientTimeout},
|
client: &http.Client{Timeout: clientTimeout},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
|
processedMsgs: make(map[string]bool),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,12 +330,23 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
|
|
||||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
msgID := msg.MsgID
|
msgID := msg.MsgID
|
||||||
if !c.processedMsgs.MarkMessageProcessed(msgID) {
|
c.msgMu.Lock()
|
||||||
|
if c.processedMsgs[msgID] {
|
||||||
|
c.msgMu.Unlock()
|
||||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
||||||
"msg_id": msgID,
|
"msg_id": msgID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.processedMsgs[msgID] = true
|
||||||
|
// Clean up old messages while still holding the lock to avoid a data race
|
||||||
|
// on len(). Reset the map but re-insert the current msgID so it remains
|
||||||
|
// deduplicated.
|
||||||
|
if len(c.processedMsgs) > 1000 {
|
||||||
|
c.processedMsgs = make(map[string]bool)
|
||||||
|
c.processedMsgs[msgID] = true
|
||||||
|
}
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
|
||||||
senderID := msg.From.UserID
|
senderID := msg.From.UserID
|
||||||
|
|
||||||
|
|
@ -453,17 +466,8 @@ 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, readErr := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if readErr != nil {
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)))
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -167,18 +167,6 @@ type SessionConfig struct {
|
||||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoutingConfig controls the intelligent model routing feature.
|
|
||||||
// When enabled, each incoming message is scored against structural features
|
|
||||||
// (message length, code blocks, tool call history, conversation depth, attachments).
|
|
||||||
// Messages scoring below Threshold are sent to LightModel; all others use the
|
|
||||||
// agent's primary model. This reduces cost and latency for simple tasks without
|
|
||||||
// requiring any keyword matching — all scoring is language-agnostic.
|
|
||||||
type RoutingConfig struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
|
|
||||||
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
|
|
@ -192,19 +180,6 @@ type AgentDefaults struct {
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
|
||||||
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
|
|
||||||
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
|
||||||
Routing *RoutingConfig `json:"routing,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
|
||||||
|
|
||||||
func (d *AgentDefaults) GetMaxMediaSize() int {
|
|
||||||
if d.MaxMediaSize > 0 {
|
|
||||||
return d.MaxMediaSize
|
|
||||||
}
|
|
||||||
return DefaultMaxMediaSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetModelName returns the effective model name for the agent defaults.
|
// GetModelName returns the effective model name for the agent defaults.
|
||||||
|
|
@ -229,7 +204,6 @@ type ChannelsConfig struct {
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,7 +236,6 @@ type WhatsAppConfig struct {
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
|
@ -279,14 +252,12 @@ type FeishuConfig struct {
|
||||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordConfig struct {
|
type DiscordConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
|
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||||
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
|
@ -389,18 +360,6 @@ type WeComAppConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeComAIBotConfig struct {
|
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
|
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
|
||||||
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
|
||||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
|
||||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
|
||||||
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
|
|
||||||
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
|
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PicoConfig struct {
|
type PicoConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||||
|
|
@ -427,7 +386,6 @@ type DevicesConfig struct {
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI OpenAIProviderConfig `json:"openai"`
|
OpenAI OpenAIProviderConfig `json:"openai"`
|
||||||
LiteLLM ProviderConfig `json:"litellm"`
|
|
||||||
OpenRouter ProviderConfig `json:"openrouter"`
|
OpenRouter ProviderConfig `json:"openrouter"`
|
||||||
Groq ProviderConfig `json:"groq"`
|
Groq ProviderConfig `json:"groq"`
|
||||||
Zhipu ProviderConfig `json:"zhipu"`
|
Zhipu ProviderConfig `json:"zhipu"`
|
||||||
|
|
@ -444,7 +402,6 @@ type ProvidersConfig struct {
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
Avian ProviderConfig `json:"avian"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -452,7 +409,6 @@ type ProvidersConfig struct {
|
||||||
func (p ProvidersConfig) IsEmpty() bool {
|
func (p ProvidersConfig) IsEmpty() bool {
|
||||||
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
||||||
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
||||||
p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
|
|
||||||
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
|
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
|
||||||
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
|
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
|
||||||
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
|
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
|
||||||
|
|
@ -468,8 +424,7 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
||||||
p.Avian.APIKey == "" && p.Avian.APIBase == ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -520,7 +475,6 @@ type ModelConfig struct {
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
@ -539,10 +493,6 @@ 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"`
|
||||||
|
|
@ -567,30 +517,11 @@ 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 {
|
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
|
|
||||||
// SearchEngine specifies the search backend: "search_std" (default),
|
|
||||||
// "search_pro", "search_pro_sogou", or "search_pro_quark".
|
|
||||||
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
Brave BraveConfig `json:"brave"`
|
||||||
Brave BraveConfig ` json:"brave"`
|
Tavily TavilyConfig `json:"tavily"`
|
||||||
Tavily TavilyConfig ` json:"tavily"`
|
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||||
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
Perplexity PerplexityConfig `json:"perplexity"`
|
||||||
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"`
|
||||||
|
|
@ -598,29 +529,19 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
||||||
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||||
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||||
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
|
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
||||||
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
|
|
||||||
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SkillsToolsConfig struct {
|
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
|
||||||
Registries SkillsRegistriesConfig ` json:"registries"`
|
|
||||||
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
|
||||||
SearchCache SearchCacheConfig ` json:"search_cache"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaCleanupConfig struct {
|
type MediaCleanupConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
|
||||||
MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
|
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
|
||||||
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
|
|
@ -631,21 +552,12 @@ type ToolsConfig struct {
|
||||||
Exec ExecConfig `json:"exec"`
|
Exec ExecConfig `json:"exec"`
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
MCP MCPConfig `json:"mcp"`
|
}
|
||||||
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
|
||||||
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
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_"`
|
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
|
||||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
|
||||||
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
|
||||||
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
|
||||||
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchCacheConfig struct {
|
type SearchCacheConfig struct {
|
||||||
|
|
@ -669,33 +581,6 @@ type ClawHubRegistryConfig struct {
|
||||||
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
|
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPServerConfig defines configuration for a single MCP server
|
|
||||||
type MCPServerConfig struct {
|
|
||||||
// Enabled indicates whether this MCP server is active
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
|
|
||||||
Command string `json:"command"`
|
|
||||||
// Args are the arguments to pass to the command
|
|
||||||
Args []string `json:"args,omitempty"`
|
|
||||||
// Env are environment variables to set for the server process (stdio only)
|
|
||||||
Env map[string]string `json:"env,omitempty"`
|
|
||||||
// EnvFile is the path to a file containing environment variables (stdio only)
|
|
||||||
EnvFile string `json:"env_file,omitempty"`
|
|
||||||
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
// URL is used for SSE/HTTP transport
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
// Headers are HTTP headers to send with requests (sse/http only)
|
|
||||||
Headers map[string]string `json:"headers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
|
||||||
type MCPConfig struct {
|
|
||||||
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
|
||||||
// Servers is a map of server name to server configuration
|
|
||||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
|
@ -752,8 +637,7 @@ func (c *Config) migrateChannelConfigs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
||||||
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
|
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
||||||
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
|
||||||
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -863,7 +747,25 @@ func (c *Config) findMatches(modelName string) []ModelConfig {
|
||||||
|
|
||||||
// HasProvidersConfig checks if any provider in the old providers config has configuration.
|
// HasProvidersConfig checks if any provider in the old providers config has configuration.
|
||||||
func (c *Config) HasProvidersConfig() bool {
|
func (c *Config) HasProvidersConfig() bool {
|
||||||
return !c.Providers.IsEmpty()
|
v := c.Providers
|
||||||
|
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
|
||||||
|
v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
|
||||||
|
v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
|
||||||
|
v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
|
||||||
|
v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
|
||||||
|
v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
|
||||||
|
v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
|
||||||
|
v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
|
||||||
|
v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
|
||||||
|
v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
|
||||||
|
v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
|
||||||
|
v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
|
||||||
|
v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
|
||||||
|
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
||||||
|
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
||||||
|
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
||||||
|
v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
|
||||||
|
v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateModelList validates all ModelConfig entries in the model_list.
|
// ValidateModelList validates all ModelConfig entries in the model_list.
|
||||||
|
|
@ -877,50 +779,3 @@ 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 "send_file":
|
|
||||||
return t.SendFile.Enabled
|
|
||||||
case "write_file":
|
|
||||||
return t.WriteFile.Enabled
|
|
||||||
case "mcp":
|
|
||||||
return t.MCP.Enabled
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,815 +0,0 @@
|
||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testFetchLimit = int64(10 * 1024 * 1024)
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_Success verifies successful URL fetching
|
|
||||||
func TestWebTool_WebFetch_Success(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("<html><body><h1>Test Page</h1><p>Content here</p></body></html>"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": server.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForLLM should contain the fetched content (full JSON result)
|
|
||||||
if !strings.Contains(result.ForLLM, "Test Page") {
|
|
||||||
t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForUser should contain summary
|
|
||||||
if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") {
|
|
||||||
t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_JSON verifies JSON content handling
|
|
||||||
func TestWebTool_WebFetch_JSON(t *testing.T) {
|
|
||||||
testData := map[string]string{"key": "value", "number": "123"}
|
|
||||||
expectedJSON, _ := json.MarshalIndent(testData, "", " ")
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(expectedJSON)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": server.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForLLM should contain formatted JSON
|
|
||||||
if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") {
|
|
||||||
t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL
|
|
||||||
func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": "not-a-valid-url",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error for invalid URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should contain error message (either "invalid URL" or scheme error)
|
|
||||||
if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") {
|
|
||||||
t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs
|
|
||||||
func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": "ftp://example.com/file.txt",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error for unsupported URL scheme")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention only http/https allowed
|
|
||||||
if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") {
|
|
||||||
t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL
|
|
||||||
func TestWebTool_WebFetch_MissingURL(t *testing.T) {
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when URL is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention URL is required
|
|
||||||
if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") {
|
|
||||||
t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_Truncation verifies content truncation
|
|
||||||
func TestWebTool_WebFetch_Truncation(t *testing.T) {
|
|
||||||
longContent := strings.Repeat("x", 20000)
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(longContent))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(1000, testFetchLimit) // Limit to 1000 chars
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": server.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForLLM should contain truncated content (not the full 20000 chars)
|
|
||||||
resultMap := make(map[string]any)
|
|
||||||
json.Unmarshal([]byte(result.ForLLM), &resultMap)
|
|
||||||
if text, ok := resultMap["text"].(string); ok {
|
|
||||||
if len(text) > 1100 { // Allow some margin
|
|
||||||
t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should be marked as truncated
|
|
||||||
if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated {
|
|
||||||
t.Errorf("Expected 'truncated' to be true in result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
|
|
||||||
// Create a mock HTTP server
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
// Generate a payload intentionally larger than our limit.
|
|
||||||
// Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'.
|
|
||||||
largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100)
|
|
||||||
|
|
||||||
w.Write(largeData)
|
|
||||||
}))
|
|
||||||
// Ensure the server is shut down at the end of the test
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
// Initialize the tool
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the arguments pointing to the URL of our local mock server
|
|
||||||
args := map[string]any{
|
|
||||||
"url": ts.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the tool
|
|
||||||
ctx := context.Background()
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Assuming ErrorResult sets the ForLLM field with the error text.
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected a ToolResult, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search for the exact error string we set earlier in the Execute method
|
|
||||||
expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit)
|
|
||||||
|
|
||||||
if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) {
|
|
||||||
t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
|
||||||
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if tool != nil {
|
|
||||||
t.Errorf("Expected nil tool when Brave API key is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also nil when nothing is enabled
|
|
||||||
tool, err = NewWebSearchTool(WebSearchToolOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if tool != nil {
|
|
||||||
t.Errorf("Expected nil tool when no provider is enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
|
||||||
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when query is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction
|
|
||||||
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(
|
|
||||||
[]byte(
|
|
||||||
`<html><body><script>alert('test');</script><style>body{color:red;}</style><h1>Title</h1><p>Content</p></body></html>`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": server.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForLLM should contain extracted text (without script/style tags)
|
|
||||||
if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") {
|
|
||||||
t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should NOT contain script or style tags in ForLLM
|
|
||||||
if strings.Contains(result.ForLLM, "<script>") || strings.Contains(result.ForLLM, "<style>") {
|
|
||||||
t.Errorf("Expected script/style tags to be removed, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebFetchTool_extractText verifies text extraction preserves newlines
|
|
||||||
func TestWebFetchTool_extractText(t *testing.T) {
|
|
||||||
tool := &WebFetchTool{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantFunc func(t *testing.T, got string)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "preserves newlines between block elements",
|
|
||||||
input: "<html><body><h1>Title</h1>\n<p>Paragraph 1</p>\n<p>Paragraph 2</p></body></html>",
|
|
||||||
wantFunc: func(t *testing.T, got string) {
|
|
||||||
lines := strings.Split(got, "\n")
|
|
||||||
if len(lines) < 2 {
|
|
||||||
t.Errorf("Expected multiple lines, got %d: %q", len(lines), got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") ||
|
|
||||||
!strings.Contains(got, "Paragraph 2") {
|
|
||||||
t.Errorf("Missing expected text: %q", got)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "removes script and style tags",
|
|
||||||
input: "<script>alert('x');</script><style>body{}</style><p>Keep this</p>",
|
|
||||||
wantFunc: func(t *testing.T, got string) {
|
|
||||||
if strings.Contains(got, "alert") || strings.Contains(got, "body{}") {
|
|
||||||
t.Errorf("Expected script/style content removed, got: %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "Keep this") {
|
|
||||||
t.Errorf("Expected 'Keep this' to remain, got: %q", got)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "collapses excessive blank lines",
|
|
||||||
input: "<p>A</p>\n\n\n\n\n<p>B</p>",
|
|
||||||
wantFunc: func(t *testing.T, got string) {
|
|
||||||
if strings.Contains(got, "\n\n\n") {
|
|
||||||
t.Errorf("Expected excessive blank lines collapsed, got: %q", got)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "collapses horizontal whitespace",
|
|
||||||
input: "<p>hello world</p>",
|
|
||||||
wantFunc: func(t *testing.T, got string) {
|
|
||||||
if strings.Contains(got, " ") {
|
|
||||||
t.Errorf("Expected spaces collapsed, got: %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "hello world") {
|
|
||||||
t.Errorf("Expected 'hello world', got: %q", got)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: "",
|
|
||||||
wantFunc: func(t *testing.T, got string) {
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("Expected empty string, got: %q", got)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := tool.extractText(tt.input)
|
|
||||||
tt.wantFunc(t, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
|
|
||||||
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"url": "https://",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error for URL without domain")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention missing domain
|
|
||||||
if !strings.Contains(result.ForLLM, "domain") && !strings.Contains(result.ForUser, "domain") {
|
|
||||||
t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
|
||||||
client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
|
||||||
}
|
|
||||||
if client.Timeout != 12*time.Second {
|
|
||||||
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
|
||||||
}
|
|
||||||
if tr.Proxy == nil {
|
|
||||||
t.Fatal("transport.Proxy is nil, want non-nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("http.NewRequest() error: %v", err)
|
|
||||||
}
|
|
||||||
proxyURL, err := tr.Proxy(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
|
||||||
}
|
|
||||||
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
|
|
||||||
_, err := createHTTPClient("://bad-proxy", 10*time.Second)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
|
||||||
client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("http.NewRequest() error: %v", err)
|
|
||||||
}
|
|
||||||
proxyURL, err := tr.Proxy(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
|
||||||
}
|
|
||||||
if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
|
|
||||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
|
|
||||||
_, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
|
||||||
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
|
||||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
|
|
||||||
t.Setenv("http_proxy", "http://127.0.0.1:8888")
|
|
||||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
|
|
||||||
t.Setenv("https_proxy", "http://127.0.0.1:8888")
|
|
||||||
t.Setenv("ALL_PROXY", "")
|
|
||||||
t.Setenv("all_proxy", "")
|
|
||||||
t.Setenv("NO_PROXY", "")
|
|
||||||
t.Setenv("no_proxy", "")
|
|
||||||
|
|
||||||
client, err := createHTTPClient("", 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
|
||||||
}
|
|
||||||
if tr.Proxy == nil {
|
|
||||||
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("http.NewRequest() error: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := tr.Proxy(req); err != nil {
|
|
||||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
|
||||||
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
} else if tool.maxChars != 1024 {
|
|
||||||
t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tool.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
|
|
||||||
tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", testFetchLimit)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
if tool.maxChars != 50000 {
|
|
||||||
t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
|
||||||
t.Run("perplexity", func(t *testing.T) {
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
PerplexityEnabled: true,
|
|
||||||
PerplexityAPIKey: "k",
|
|
||||||
PerplexityMaxResults: 3,
|
|
||||||
Proxy: "http://127.0.0.1:7890",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
p, ok := tool.provider.(*PerplexitySearchProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
|
|
||||||
}
|
|
||||||
if p.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("brave", func(t *testing.T) {
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
BraveEnabled: true,
|
|
||||||
BraveAPIKey: "k",
|
|
||||||
BraveMaxResults: 3,
|
|
||||||
Proxy: "http://127.0.0.1:7890",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
p, ok := tool.provider.(*BraveSearchProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
|
|
||||||
}
|
|
||||||
if p.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("duckduckgo", func(t *testing.T) {
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
DuckDuckGoEnabled: true,
|
|
||||||
DuckDuckGoMaxResults: 3,
|
|
||||||
Proxy: "http://127.0.0.1:7890",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
p, ok := tool.provider.(*DuckDuckGoSearchProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
|
|
||||||
}
|
|
||||||
if p.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_TavilySearch_Success verifies successful Tavily search
|
|
||||||
func TestWebTool_TavilySearch_Success(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
t.Errorf("Expected POST request, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify payload
|
|
||||||
var payload map[string]any
|
|
||||||
json.NewDecoder(r.Body).Decode(&payload)
|
|
||||||
if payload["api_key"] != "test-key" {
|
|
||||||
t.Errorf("Expected api_key test-key, got %v", payload["api_key"])
|
|
||||||
}
|
|
||||||
if payload["query"] != "test query" {
|
|
||||||
t.Errorf("Expected query 'test query', got %v", payload["query"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return mock response
|
|
||||||
response := map[string]any{
|
|
||||||
"results": []map[string]any{
|
|
||||||
{
|
|
||||||
"title": "Test Result 1",
|
|
||||||
"url": "https://example.com/1",
|
|
||||||
"content": "Content for result 1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Test Result 2",
|
|
||||||
"url": "https://example.com/2",
|
|
||||||
"content": "Content for result 2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
TavilyEnabled: true,
|
|
||||||
TavilyAPIKey: "test-key",
|
|
||||||
TavilyBaseURL: server.URL,
|
|
||||||
TavilyMaxResults: 5,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"query": "test query",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForUser should contain result titles and URLs
|
|
||||||
if !strings.Contains(result.ForUser, "Test Result 1") ||
|
|
||||||
!strings.Contains(result.ForUser, "https://example.com/1") {
|
|
||||||
t.Errorf("Expected results in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention via Tavily
|
|
||||||
if !strings.Contains(result.ForUser, "via Tavily") {
|
|
||||||
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebTool_GLMSearch_Success(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
t.Errorf("Expected POST request, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
if r.Header.Get("Authorization") != "Bearer test-glm-key" {
|
|
||||||
t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
json.NewDecoder(r.Body).Decode(&payload)
|
|
||||||
if payload["search_query"] != "test query" {
|
|
||||||
t.Errorf("Expected search_query 'test query', got %v", payload["search_query"])
|
|
||||||
}
|
|
||||||
if payload["search_engine"] != "search_std" {
|
|
||||||
t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"])
|
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]any{
|
|
||||||
"id": "web-search-test",
|
|
||||||
"created": 1709568000,
|
|
||||||
"search_result": []map[string]any{
|
|
||||||
{
|
|
||||||
"title": "Test GLM Result",
|
|
||||||
"content": "GLM search snippet",
|
|
||||||
"link": "https://example.com/glm",
|
|
||||||
"media": "Example",
|
|
||||||
"publish_date": "2026-03-04",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
GLMSearchEnabled: true,
|
|
||||||
GLMSearchAPIKey: "test-glm-key",
|
|
||||||
GLMSearchBaseURL: server.URL,
|
|
||||||
GLMSearchEngine: "search_std",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
|
||||||
"query": "test query",
|
|
||||||
})
|
|
||||||
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.ForUser, "Test GLM Result") {
|
|
||||||
t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.ForUser, "https://example.com/glm") {
|
|
||||||
t.Errorf("Expected URL in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.ForUser, "via GLM Search") {
|
|
||||||
t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebTool_GLMSearch_APIError(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"error":"invalid api key"}`))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
GLMSearchEnabled: true,
|
|
||||||
GLMSearchAPIKey: "bad-key",
|
|
||||||
GLMSearchBaseURL: server.URL,
|
|
||||||
GLMSearchEngine: "search_std",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
|
||||||
"query": "test query",
|
|
||||||
})
|
|
||||||
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected IsError=true for 401 response")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.ForLLM, "status 401") {
|
|
||||||
t.Errorf("Expected status 401 in error, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebTool_GLMSearch_Priority(t *testing.T) {
|
|
||||||
// GLM Search should only be selected when all other providers are disabled
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
DuckDuckGoEnabled: true,
|
|
||||||
DuckDuckGoMaxResults: 5,
|
|
||||||
GLMSearchEnabled: true,
|
|
||||||
GLMSearchAPIKey: "test-key",
|
|
||||||
GLMSearchBaseURL: "https://example.com",
|
|
||||||
GLMSearchEngine: "search_std",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DuckDuckGo should win over GLM Search
|
|
||||||
if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok {
|
|
||||||
t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With DuckDuckGo disabled, GLM Search should be selected
|
|
||||||
tool2, err := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
DuckDuckGoEnabled: false,
|
|
||||||
GLMSearchEnabled: true,
|
|
||||||
GLMSearchAPIKey: "test-key",
|
|
||||||
GLMSearchBaseURL: "https://example.com",
|
|
||||||
GLMSearchEngine: "search_std",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := tool2.provider.(*GLMSearchProvider); !ok {
|
|
||||||
t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue