From 4b7e8d9cb956c01a1b3bcdd88997fbe80f39b334 Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Mon, 2 Mar 2026 22:29:26 +0700 Subject: [PATCH] feat: add Exa AI search provider Add Exa (https://exa.ai) as a new web search provider option, slotting into the priority chain between Perplexity and Brave. Configurable via config.json or PICOCLAW_TOOLS_WEB_EXA_* environment variables. Results are capped to the requested count for consistency with other search providers. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 3 ++ pkg/config/config.go | 7 ++++ pkg/config/defaults.go | 5 +++ pkg/tools/web.go | 84 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 00b0f096a..e1e26fb1d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -112,6 +112,9 @@ func registerSharedTools( PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + ExaAPIKey: cfg.Tools.Web.Exa.APIKey, + ExaMaxResults: cfg.Tools.Web.Exa.MaxResults, + ExaEnabled: cfg.Tools.Web.Exa.Enabled, Proxy: cfg.Tools.Web.Proxy, }) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index c4c175495..49d46c6b6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -530,11 +530,18 @@ type PerplexityConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } +type ExaConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_EXA_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_EXA_API_KEY"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_EXA_MAX_RESULTS"` +} + type WebToolsConfig struct { Brave BraveConfig `json:"brave"` Tavily TavilyConfig `json:"tavily"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` + Exa ExaConfig `json:"exa"` // 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"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index fb0fd4451..9634906cd 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -341,6 +341,11 @@ func DefaultConfig() *Config { APIKey: "", MaxResults: 5, }, + Exa: ExaConfig{ + Enabled: false, + APIKey: "", + MaxResults: 5, + }, }, Cron: CronToolsConfig{ ExecTimeoutMinutes: 5, diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 10498126b..43b1c1402 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -409,6 +409,9 @@ type WebSearchToolOptions struct { PerplexityAPIKey string PerplexityMaxResults int PerplexityEnabled bool + ExaAPIKey string + ExaMaxResults int + ExaEnabled bool Proxy string } @@ -416,7 +419,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 5 - // Priority: Perplexity > Brave > Tavily > DuckDuckGo + // Priority: Perplexity > Exa > Brave > Tavily > DuckDuckGo if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { client, err := createHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { @@ -426,6 +429,15 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.PerplexityMaxResults > 0 { maxResults = opts.PerplexityMaxResults } + } else if opts.ExaEnabled && opts.ExaAPIKey != "" { + client, err := createHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for Exa: %w", err) + } + provider = &ExaSearchProvider{apiKey: opts.ExaAPIKey, proxy: opts.Proxy, client: client} + if opts.ExaMaxResults > 0 { + maxResults = opts.ExaMaxResults + } } else if opts.BraveEnabled && opts.BraveAPIKey != "" { client, err := createHTTPClient(opts.Proxy, searchTimeout) if err != nil { @@ -705,3 +717,73 @@ func (t *WebFetchTool) extractText(htmlContent string) string { return strings.Join(cleanLines, "\n") } + +// ExaSearchProvider uses the Exa AI search API (https://exa.ai). +type ExaSearchProvider struct { + apiKey string + proxy string + client *http.Client +} + +func (p *ExaSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + reqBody := map[string]any{ + "query": query, + "num_results": count, + "type": "neural", + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("exa: marshal error: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.exa.ai/search", bytes.NewReader(jsonData)) + if err != nil { + return "", fmt.Errorf("exa: request error: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("exa: search failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("exa: read error: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("exa: API error %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Text string `json:"text"` + } `json:"results"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("exa: parse error: %w", err) + } + + var sb strings.Builder + maxResults := count + if maxResults > len(result.Results) { + maxResults = len(result.Results) + } + for i, r := range result.Results[:maxResults] { + sb.WriteString(fmt.Sprintf("%d. %s\n URL: %s\n", i+1, r.Title, r.URL)) + if r.Text != "" { + snippet := r.Text + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + sb.WriteString(fmt.Sprintf(" %s\n", snippet)) + } + sb.WriteString("\n") + } + + return sb.String(), nil +}