Merge branch 'sipeed:main' into backup

This commit is contained in:
seanly 2026-02-25 14:05:48 +08:00 committed by GitHub
commit c172b021f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 631 additions and 211 deletions

View file

@ -66,7 +66,6 @@ linters:
- testifylint - testifylint
- thelper - thelper
- unparam - unparam
- unused
- usestdlibvars - usestdlibvars
- usetesting - usetesting
- wastedassign - wastedassign
@ -152,6 +151,9 @@ linters:
- gocognit - gocognit
- gocyclo - gocyclo
path: _test\.go$ path: _test\.go$
- linters:
- nolintlint
path: 'pkg/tools/(i2c\.go|spi\.go)$'
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0

View file

@ -14,7 +14,7 @@ GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w" LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
# Go variables # Go variables
GO?=go GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson GOFLAGS?=-v -tags stdjson
# Golangci-lint # Golangci-lint
@ -144,6 +144,10 @@ fmt:
lint: lint:
@$(GOLANGCI_LINT) run @$(GOLANGCI_LINT) run
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix
## deps: Download dependencies ## deps: Download dependencies
deps: deps:
@$(GO) mod download @$(GO) mod download
@ -169,7 +173,7 @@ help:
@echo " make [target]" @echo " make [target]"
@echo "" @echo ""
@echo "Targets:" @echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /' @grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo "" @echo ""
@echo "Examples:" @echo "Examples:"
@echo " make build # Build for current platform" @echo " make build # Build for current platform"

View file

@ -226,7 +226,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"channels": { "channels": {

View file

@ -188,7 +188,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"channels": { "channels": {

View file

@ -222,7 +222,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20

View file

@ -227,7 +227,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"tools": { "tools": {

View file

@ -207,7 +207,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"channels": { "channels": {

View file

@ -224,7 +224,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20

View file

@ -56,7 +56,7 @@ func agentCmd() {
} }
if modelOverride != "" { if modelOverride != "" {
cfg.Agents.Defaults.Model = modelOverride cfg.Agents.Defaults.ModelName = modelOverride
} }
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := providers.CreateProvider(cfg)
@ -66,7 +66,7 @@ func agentCmd() {
} }
// Use the resolved model ID from provider creation // Use the resolved model ID from provider creation
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.Model = modelID cfg.Agents.Defaults.ModelName = modelID
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()

View file

@ -144,7 +144,7 @@ func authLoginOpenAI(useDeviceCode bool) {
} }
// Update default model to use OpenAI // Update default model to use OpenAI
appCfg.Agents.Defaults.Model = "gpt-5.2" appCfg.Agents.Defaults.ModelName = "gpt-5.2"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err) fmt.Printf("Warning: could not update config: %v\n", err)
@ -218,7 +218,7 @@ func authLoginGoogleAntigravity() {
} }
// Update default model // Update default model
appCfg.Agents.Defaults.Model = "gemini-flash" appCfg.Agents.Defaults.ModelName = "gemini-flash"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err) fmt.Printf("Warning: could not update config: %v\n", err)
@ -292,7 +292,7 @@ func authLoginPasteToken(provider string) {
}) })
} }
// Update default model // Update default model
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6" appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai": case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token" appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList // Update ModelList
@ -312,7 +312,7 @@ func authLoginPasteToken(provider string) {
}) })
} }
// Update default model // Update default model
appCfg.Agents.Defaults.Model = "gpt-5.2" appCfg.Agents.Defaults.ModelName = "gpt-5.2"
} }
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err) fmt.Printf("Warning: could not update config: %v\n", err)
@ -320,7 +320,7 @@ func authLoginPasteToken(provider string) {
} }
fmt.Printf("Token saved for %s!\n", provider) fmt.Printf("Token saved for %s!\n", provider)
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model) fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
} }
func authLogoutCmd() { func authLogoutCmd() {

View file

@ -52,7 +52,7 @@ func gatewayCmd() {
} }
// Use the resolved model ID from provider creation // Use the resolved model ID from provider creation
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.Model = modelID cfg.Agents.Defaults.ModelName = modelID
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
@ -211,6 +211,9 @@ func gatewayCmd() {
<-sigChan <-sigChan
fmt.Println("\nShutting down...") fmt.Println("\nShutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
}
cancel() cancel()
healthServer.Stop(context.Background()) healthServer.Stop(context.Background())
deviceService.Stop() deviceService.Stop()

View file

@ -41,7 +41,7 @@ func statusCmd() {
} }
if _, err := os.Stat(configPath); err == nil { if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model) fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != "" hasAnthropic := cfg.Providers.Anthropic.APIKey != ""

View file

@ -3,7 +3,7 @@
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "restrict_to_workspace": true,
"model": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -217,7 +217,8 @@
"enabled": false, "enabled": false,
"api_key": "pplx-xxx", "api_key": "pplx-xxx",
"max_results": 5 "max_results": 5
} },
"proxy": ""
}, },
"cron": { "cron": {
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5

View file

@ -288,25 +288,6 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages return messages
} }
func (cb *ContextBuilder) loadSkills() string {
allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 {
return ""
}
var skillNames []string
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
content := cb.skillsLoader.LoadSkillsForContext(skillNames)
if content == "" {
return ""
}
return "# Skill Definitions\n\n" + content
}
// GetSkillsInfo returns information about loaded skills. // GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any { func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills() allSkills := cb.skillsLoader.ListSkills()

View file

@ -133,7 +133,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary) return strings.TrimSpace(agentCfg.Model.Primary)
} }
return defaults.Model return defaults.GetModelName()
} }
// resolveAgentFallbacks resolves the fallback models for an agent. // resolveAgentFallbacks resolves the fallback models for an agent.

View file

@ -106,10 +106,11 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
Proxy: cfg.Tools.Web.Proxy,
}); searchTool != nil { }); searchTool != nil {
agent.Tools.Register(searchTool) agent.Tools.Register(searchTool)
} }
agent.Tools.Register(tools.NewWebFetchTool(50000)) agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
// 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()) agent.Tools.Register(tools.NewI2CTool())

View file

@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
switch args { switch args {
case "model": case "model":
response = fmt.Sprintf("Current Model: %s (Provider: %s)", response = fmt.Sprintf("Current Model: %s (Provider: %s)",
c.config.Agents.Defaults.Model, c.config.Agents.Defaults.GetModelName(),
c.config.Agents.Defaults.Provider) c.config.Agents.Defaults.Provider)
case "channel": case "channel":
response = "Current Channel: telegram" response = "Current Channel: telegram"
@ -120,7 +120,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
provider = "configured default" provider = "configured default"
} }
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
c.config.Agents.Defaults.Model, provider) c.config.Agents.Defaults.GetModelName(), provider)
case "channels": case "channels":
var enabled []string var enabled []string

View file

@ -571,61 +571,6 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
return nil return nil
} }
// sendMarkdownMessage sends a markdown message to a user
func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
msg := WeComMarkdownMessage{
ToUser: userID,
MsgType: "markdown",
AgentID: c.config.AgentID,
}
msg.Markdown.Content = content
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
// Use configurable timeout (default 5 seconds)
timeout := c.config.ReplyTimeout
if timeout <= 0 {
timeout = 5
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var sendResp WeComSendMessageResponse
if err := json.Unmarshal(body, &sendResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if sendResp.ErrCode != 0 {
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
}
return nil
}
// handleHealth handles health check requests // handleHealth handles health check requests
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]any{ status := map[string]any{

View file

@ -170,7 +170,8 @@ 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"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"` ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -179,6 +180,15 @@ type AgentDefaults struct {
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"`
} }
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
if d.ModelName != "" {
return d.ModelName
}
return d.Model
}
type ChannelsConfig struct { type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"` WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
@ -443,6 +453,9 @@ type WebToolsConfig struct {
Tavily TavilyConfig `json:"tavily"` Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"` Perplexity PerplexityConfig `json:"perplexity"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
} }
type CronToolsConfig struct { type CronToolsConfig struct {
@ -499,6 +512,20 @@ func LoadConfig(path string) (*Config, error) {
return nil, err return nil, err
} }
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
// would silently inherit values from the DefaultConfig template at the same
// index position. We only reset cfg.ModelList when the user actually provides
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
var tmp Config
if err := json.Unmarshal(data, &tmp); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := json.Unmarshal(data, cfg); err != nil { if err := json.Unmarshal(data, cfg); err != nil {
return nil, err return nil, err
} }

View file

@ -392,3 +392,24 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
t.Fatal("OpenAI codex web search should be false when disabled in config file") t.Fatal("OpenAI codex web search should be false when disabled in config file")
} }
} }
func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
t.Fatalf("os.WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" {
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
}
}

View file

@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
// Get user's configured provider and model // Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider) userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
userModel := cfg.Agents.Defaults.Model userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers p := cfg.Providers

View file

@ -6,6 +6,7 @@
package config package config
import ( import (
"encoding/json"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
} }
} }
func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
tests := []struct {
name string
defaults AgentDefaults
wantName string
}{
{
name: "new model_name field only",
defaults: AgentDefaults{ModelName: "new-model"},
wantName: "new-model",
},
{
name: "old model field only",
defaults: AgentDefaults{Model: "legacy-model"},
wantName: "legacy-model",
},
{
name: "both fields - model_name takes precedence",
defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
wantName: "new-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string
json string
wantName string
}{
{
name: "new model_name field",
json: `{"model_name": "gpt4"}`,
wantName: "gpt4",
},
{
name: "old model field",
json: `{"model": "gpt4"}`,
wantName: "gpt4",
},
{
name: "both fields - model_name wins",
json: `{"model_name": "new", "model": "old"}`,
wantName: "new",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var defaults AgentDefaults
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if got := defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
// Test complete config with both old and new formats
oldFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
newFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
for name, jsonStr := range map[string]string{
"old format (model)": oldFormat,
"new format (model_name)": newFormat,
} {
t.Run(name, func(t *testing.T) {
cfg := &Config{}
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
// Check that GetModelName returns correct value
if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
}
// Check that GetModelConfig works
modelCfg, err := cfg.GetModelConfig("gpt4")
if err != nil {
t.Fatalf("GetModelConfig error: %v", err)
}
if modelCfg.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
}
})
}
}
func TestModelConfig_Validate(t *testing.T) { func TestModelConfig_Validate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -35,9 +35,8 @@ var usbClassToCapability = map[string]string{
} }
type USBMonitor struct { type USBMonitor struct {
cmd *exec.Cmd cmd *exec.Cmd
cancel context.CancelFunc mu sync.Mutex
mu sync.Mutex
} }
func NewUSBMonitor() *USBMonitor { func NewUSBMonitor() *USBMonitor {

View file

@ -73,7 +73,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if agents, ok := getMap(data, "agents"); ok { if agents, ok := getMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok { if defaults, ok := getMap(agents, "defaults"); ok {
if v, ok := getString(defaults, "model"); ok { // Prefer model_name, fallback to model for backward compatibility
if v, ok := getString(defaults, "model_name"); ok {
cfg.Agents.Defaults.ModelName = v
} else if v, ok := getString(defaults, "model"); ok {
cfg.Agents.Defaults.Model = v cfg.Agents.Defaults.Model = v
} }
if v, ok := getFloat(defaults, "max_tokens"); ok { if v, ok := getFloat(defaults, "max_tokens"); ok {

View file

@ -404,64 +404,6 @@ type antigravityJSONResponse struct {
} `json:"usageMetadata"` } `json:"usageMetadata"`
} }
func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
var resp antigravityJSONResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing antigravity response: %w", err)
}
if len(resp.Candidates) == 0 {
return nil, fmt.Errorf("antigravity: no candidates in response")
}
candidate := resp.Candidates[0]
var contentParts []string
var toolCalls []ToolCall
for _, part := range candidate.Content.Parts {
if part.Text != "" {
contentParts = append(contentParts, part.Text)
}
if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, ToolCall{
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args,
Function: &FunctionCall{
Name: part.FunctionCall.Name,
Arguments: string(argumentsJSON),
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
},
})
}
}
finishReason := "stop"
if len(toolCalls) > 0 {
finishReason = "tool_calls"
}
if candidate.FinishReason == "MAX_TOKENS" {
finishReason = "length"
}
var usage *UsageInfo
if resp.UsageMetadata.TotalTokenCount > 0 {
usage = &UsageInfo{
PromptTokens: resp.UsageMetadata.PromptTokenCount,
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
TotalTokens: resp.UsageMetadata.TotalTokenCount,
}
}
return &LLMResponse{
Content: strings.Join(contentParts, ""),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
}, nil
}
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) { func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string var contentParts []string
var toolCalls []ToolCall var toolCalls []ToolCall

View file

@ -36,7 +36,7 @@ type providerSelection struct {
} }
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider) providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model) lowerModel := strings.ToLower(model)

View file

@ -17,12 +17,6 @@ func successRun(content string) func(ctx context.Context, provider, model string
} }
} }
func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
return nil, err
}
}
func TestFallback_SingleCandidate_Success(t *testing.T) { func TestFallback_SingleCandidate_Success(t *testing.T) {
ct := NewCooldownTracker() ct := NewCooldownTracker()
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)

View file

@ -4,60 +4,84 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sync"
copilot "github.com/github/copilot-sdk/go" copilot "github.com/github/copilot-sdk/go"
) )
type GitHubCopilotProvider struct { type GitHubCopilotProvider struct {
uri string uri string
connectMode string // `stdio` or `grpc`` connectMode string // "stdio" or "grpc"
client *copilot.Client
session *copilot.Session session *copilot.Session
mu sync.Mutex
} }
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
var session *copilot.Session
if connectMode == "" { if connectMode == "" {
connectMode = "grpc" connectMode = "grpc"
} }
switch connectMode {
switch connectMode {
case "stdio": case "stdio":
// todo // TODO:
return nil, fmt.Errorf("stdio mode not implemented")
case "grpc": case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{ client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri, CLIUrl: uri,
}) })
if err := client.Start(context.Background()); err != nil { if err := client.Start(context.Background()); err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details", "can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details",
err,
) )
} }
defer client.Stop()
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
Model: model, Model: model,
Hooks: &copilot.SessionHooks{}, Hooks: &copilot.SessionHooks{},
}) })
if err != nil {
client.Stop()
return nil, fmt.Errorf("create session failed: %w", err)
}
return &GitHubCopilotProvider{
uri: uri,
connectMode: connectMode,
client: client,
session: session,
}, nil
default:
return nil, fmt.Errorf("unknown connect mode: %s", connectMode)
}
}
func (p *GitHubCopilotProvider) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.client != nil {
p.client.Stop()
p.client = nil
p.session = nil
} }
return &GitHubCopilotProvider{
uri: uri,
connectMode: connectMode,
session: session,
}, nil
} }
// Chat sends a chat request to GitHub Copilot
func (p *GitHubCopilotProvider) Chat( func (p *GitHubCopilotProvider) Chat(
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (*LLMResponse, error) { ) (*LLMResponse, error) {
type tempMessage struct { type tempMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
} }
out := make([]tempMessage, 0, len(messages)) out := make([]tempMessage, 0, len(messages))
for _, msg := range messages { for _, msg := range messages {
out = append(out, tempMessage{ out = append(out, tempMessage{
Role: msg.Role, Role: msg.Role,
@ -65,12 +89,30 @@ func (p *GitHubCopilotProvider) Chat(
}) })
} }
fullcontent, _ := json.Marshal(out) fullcontent, err := json.Marshal(out)
if err != nil {
return nil, fmt.Errorf("marshal messages: %w", err)
}
p.mu.Lock()
session := p.session
p.mu.Unlock()
content, _ := p.session.Send(ctx, copilot.MessageOptions{ if session == nil {
return nil, fmt.Errorf("provider closed")
}
resp, err := session.SendAndWait(ctx, copilot.MessageOptions{
Prompt: string(fullcontent), Prompt: string(fullcontent),
}) })
if resp == nil {
return nil, fmt.Errorf("empty response from copilot")
}
if resp.Data.Content == nil {
return nil, fmt.Errorf("no content in copilot response")
}
content := *resp.Data.Content
return &LLMResponse{ return &LLMResponse{
FinishReason: "stop", FinishReason: "stop",
Content: content, Content: content,

View file

@ -16,7 +16,7 @@ import (
// The old providers config is automatically converted to model_list during config loading. // The old providers config is automatically converted to model_list during config loading.
// Returns the provider, the model ID to use, and any error. // Returns the provider, the model ID to use, and any error.
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.GetModelName()
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases) // Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {

View file

@ -30,6 +30,11 @@ type LLMProvider interface {
GetDefaultModel() string GetDefaultModel() string
} }
type StatefulProvider interface {
LLMProvider
Close()
}
// FailoverReason classifies why an LLM request failed for fallback decisions. // FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string type FailoverReason string

View file

@ -117,13 +117,19 @@ func (t *I2CTool) detect() *ToolResult {
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
} }
// Helper functions for I2C operations (used by platform-specific implementations)
// isValidBusID checks that a bus identifier is a simple number (prevents path injection) // isValidBusID checks that a bus identifier is a simple number (prevents path injection)
//
//nolint:unused // Used by i2c_linux.go
func isValidBusID(id string) bool { func isValidBusID(id string) bool {
matched, _ := regexp.MatchString(`^\d+$`, id) matched, _ := regexp.MatchString(`^\d+$`, id)
return matched return matched
} }
// parseI2CAddress extracts and validates an I2C address from args // parseI2CAddress extracts and validates an I2C address from args
//
//nolint:unused // Used by i2c_linux.go
func parseI2CAddress(args map[string]any) (int, *ToolResult) { func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64) addrFloat, ok := args["address"].(float64)
if !ok { if !ok {
@ -137,6 +143,8 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
} }
// parseI2CBus extracts and validates an I2C bus from args // parseI2CBus extracts and validates an I2C bus from args
//
//nolint:unused // Used by i2c_linux.go
func parseI2CBus(args map[string]any) (string, *ToolResult) { func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string) bus, ok := args["bus"].(string)
if !ok || bus == "" { if !ok || bus == "" {

View file

@ -3,6 +3,7 @@ package tools
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
) )
type SpawnTool struct { type SpawnTool struct {
@ -66,8 +67,8 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string) task, ok := args["task"].(string)
if !ok { if !ok || strings.TrimSpace(task) == "" {
return ErrorResult("task is required") return ErrorResult("task is required and must be a non-empty string")
} }
label, _ := args["label"].(string) label, _ := args["label"].(string)

79
pkg/tools/spawn_test.go Normal file
View file

@ -0,0 +1,79 @@
package tools
import (
"context"
"strings"
"testing"
)
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSpawnTool(manager)
ctx := context.Background()
tests := []struct {
name string
args map[string]any
}{
{"empty string", map[string]any{"task": ""}},
{"whitespace only", map[string]any{"task": " "}},
{"tabs and newlines", map[string]any{"task": "\t\n "}},
{"missing task key", map[string]any{"label": "test"}},
{"wrong type", map[string]any{"task": 123}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tool.Execute(ctx, tt.args)
if result == nil {
t.Fatal("Result should not be nil")
}
if !result.IsError {
t.Error("Expected error for invalid task parameter")
}
if !strings.Contains(result.ForLLM, "task is required") {
t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
}
})
}
}
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSpawnTool(manager)
ctx := context.Background()
args := map[string]any{
"task": "Write a haiku about coding",
"label": "haiku-task",
}
result := tool.Execute(ctx, args)
if result == nil {
t.Fatal("Result should not be nil")
}
if result.IsError {
t.Errorf("Expected success for valid task, got error: %s", result.ForLLM)
}
if !result.Async {
t.Error("SpawnTool should return async result")
}
}
func TestSpawnTool_Execute_NilManager(t *testing.T) {
tool := NewSpawnTool(nil)
ctx := context.Background()
args := map[string]any{"task": "test task"}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Error("Expected error for nil manager")
}
if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
}
}

View file

@ -119,7 +119,11 @@ func (t *SPITool) list() *ToolResult {
return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
} }
// Helper function for SPI operations (used by platform-specific implementations)
// parseSPIArgs extracts and validates common SPI parameters // parseSPIArgs extracts and validates common SPI parameters
//
//nolint:unused // Used by spi_linux.go
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string) dev, ok := args["device"].(string)
if !ok || dev == "" { if !ok || dev == "" {

View file

@ -17,12 +17,50 @@ const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
) )
// createHTTPClient creates an HTTP client with optional proxy support
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
client := &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
TLSHandshakeTimeout: 15 * time.Second,
},
}
if proxyURL != "" {
proxy, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
scheme := strings.ToLower(proxy.Scheme)
switch scheme {
case "http", "https", "socks5", "socks5h":
default:
return nil, fmt.Errorf(
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
proxy.Scheme,
)
}
if proxy.Host == "" {
return nil, fmt.Errorf("invalid proxy URL: missing host")
}
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
} else {
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
}
return client, nil
}
type SearchProvider interface { type SearchProvider interface {
Search(ctx context.Context, query string, count int) (string, error) Search(ctx context.Context, query string, count int) (string, error)
} }
type BraveSearchProvider struct { type BraveSearchProvider struct {
apiKey string apiKey string
proxy string
} }
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
@ -37,7 +75,10 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey) req.Header.Set("X-Subscription-Token", p.apiKey)
client := &http.Client{Timeout: 10 * time.Second} client, err := createHTTPClient(p.proxy, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create HTTP client: %w", err)
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return "", fmt.Errorf("request failed: %w", err)
@ -167,7 +208,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
return strings.Join(lines, "\n"), nil return strings.Join(lines, "\n"), nil
} }
type DuckDuckGoSearchProvider struct{} type DuckDuckGoSearchProvider struct {
proxy string
}
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
@ -179,7 +222,10 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
client := &http.Client{Timeout: 10 * time.Second} client, err := createHTTPClient(p.proxy, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create HTTP client: %w", err)
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return "", fmt.Errorf("request failed: %w", err)
@ -261,6 +307,7 @@ func stripTags(content string) string {
type PerplexitySearchProvider struct { type PerplexitySearchProvider struct {
apiKey string apiKey string
proxy string
} }
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
@ -295,7 +342,10 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
req.Header.Set("Authorization", "Bearer "+p.apiKey) req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
client := &http.Client{Timeout: 30 * time.Second} client, err := createHTTPClient(p.proxy, 30*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create HTTP client: %w", err)
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return "", fmt.Errorf("request failed: %w", err)
@ -348,6 +398,7 @@ type WebSearchToolOptions struct {
PerplexityAPIKey string PerplexityAPIKey string
PerplexityMaxResults int PerplexityMaxResults int
PerplexityEnabled bool PerplexityEnabled bool
Proxy string
} }
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
@ -356,12 +407,12 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
// Priority: Perplexity > Brave > Tavily > DuckDuckGo // Priority: Perplexity > Brave > Tavily > DuckDuckGo
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey} provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy}
if opts.PerplexityMaxResults > 0 { if opts.PerplexityMaxResults > 0 {
maxResults = opts.PerplexityMaxResults maxResults = opts.PerplexityMaxResults
} }
} else if opts.BraveEnabled && opts.BraveAPIKey != "" { } else if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey} provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy}
if opts.BraveMaxResults > 0 { if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults maxResults = opts.BraveMaxResults
} }
@ -374,7 +425,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
maxResults = opts.TavilyMaxResults maxResults = opts.TavilyMaxResults
} }
} else if opts.DuckDuckGoEnabled { } else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{} provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy}
if opts.DuckDuckGoMaxResults > 0 { if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults maxResults = opts.DuckDuckGoMaxResults
} }
@ -441,6 +492,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
type WebFetchTool struct { type WebFetchTool struct {
maxChars int maxChars int
proxy string
} }
func NewWebFetchTool(maxChars int) *WebFetchTool { func NewWebFetchTool(maxChars int) *WebFetchTool {
@ -452,6 +504,16 @@ func NewWebFetchTool(maxChars int) *WebFetchTool {
} }
} }
func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool {
if maxChars <= 0 {
maxChars = 50000
}
return &WebFetchTool{
maxChars: maxChars,
proxy: proxy,
}
}
func (t *WebFetchTool) Name() string { func (t *WebFetchTool) Name() string {
return "web_fetch" return "web_fetch"
} }
@ -511,20 +573,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
client := &http.Client{ client, err := createHTTPClient(t.proxy, 60*time.Second)
Timeout: 60 * time.Second, if err != nil {
Transport: &http.Transport{ return ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err))
MaxIdleConns: 10, }
IdleConnTimeout: 30 * time.Second,
DisableCompression: false, // Configure redirect handling
TLSHandshakeTimeout: 15 * time.Second, client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
}, if len(via) >= 5 {
CheckRedirect: func(req *http.Request, via []*http.Request) error { return fmt.Errorf("stopped after 5 redirects")
if len(via) >= 5 { }
return fmt.Errorf("stopped after 5 redirects") return nil
}
return nil
},
} }
resp, err := client.Do(req) resp, err := client.Do(req)

View file

@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
) )
// TestWebTool_WebFetch_Success verifies successful URL fetching // TestWebTool_WebFetch_Success verifies successful URL fetching
@ -334,6 +335,172 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
} }
} }
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 := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890")
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 = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890")
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 := NewWebSearchTool(WebSearchToolOptions{
PerplexityEnabled: true,
PerplexityAPIKey: "k",
PerplexityMaxResults: 3,
Proxy: "http://127.0.0.1:7890",
})
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 := NewWebSearchTool(WebSearchToolOptions{
BraveEnabled: true,
BraveAPIKey: "k",
BraveMaxResults: 3,
Proxy: "http://127.0.0.1:7890",
})
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 := NewWebSearchTool(WebSearchToolOptions{
DuckDuckGoEnabled: true,
DuckDuckGoMaxResults: 3,
Proxy: "http://127.0.0.1:7890",
})
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 // TestWebTool_TavilySearch_Success verifies successful Tavily search
func TestWebTool_TavilySearch_Success(t *testing.T) { func TestWebTool_TavilySearch_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {