Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@
"enabled": false,
"api_key": "pplx-xxx",
"max_results": 5
}
},
"proxy": ""
},
"cron": {
"exec_timeout_minutes": 5
Expand Down
3 changes: 2 additions & 1 deletion pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
Proxy: cfg.Tools.Web.Proxy,
}); searchTool != nil {
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
agent.Tools.Register(tools.NewI2CTool())
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ type WebToolsConfig struct {
Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
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 {
Expand Down
21 changes: 21 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,24 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
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")
}
}
101 changes: 80 additions & 21 deletions pkg/tools/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

// 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 {
Search(ctx context.Context, query string, count int) (string, error)
}

type BraveSearchProvider struct {
apiKey string
proxy string
}

func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
Expand All @@ -37,7 +75,10 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
req.Header.Set("Accept", "application/json")
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)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -167,7 +208,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
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) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
Expand All @@ -179,7 +222,10 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou

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)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -261,6 +307,7 @@ func stripTags(content string) string {

type PerplexitySearchProvider struct {
apiKey string
proxy string
}

func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
Expand Down Expand Up @@ -295,7 +342,10 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
req.Header.Set("Authorization", "Bearer "+p.apiKey)
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)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -348,6 +398,7 @@ type WebSearchToolOptions struct {
PerplexityAPIKey string
PerplexityMaxResults int
PerplexityEnabled bool
Proxy string
}

func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
Expand All @@ -356,12 +407,12 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {

// Priority: Perplexity > Brave > Tavily > DuckDuckGo
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy}
if opts.PerplexityMaxResults > 0 {
maxResults = opts.PerplexityMaxResults
}
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
}
Expand All @@ -374,7 +425,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
maxResults = opts.TavilyMaxResults
}
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
Expand Down Expand Up @@ -441,6 +492,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR

type WebFetchTool struct {
maxChars int
proxy string
}

func NewWebFetchTool(maxChars int) *WebFetchTool {
Expand All @@ -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 {
return "web_fetch"
}
Expand Down Expand Up @@ -511,20 +573,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe

req.Header.Set("User-Agent", userAgent)

client := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
TLSHandshakeTimeout: 15 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("stopped after 5 redirects")
}
return nil
},
client, err := createHTTPClient(t.proxy, 60*time.Second)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err))
}

// Configure redirect handling
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("stopped after 5 redirects")
}
return nil
}

resp, err := client.Do(req)
Expand Down
Loading