Skip to content
Open
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
4 changes: 4 additions & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
BochaAPIKey: cfg.Tools.Web.Bocha.APIKey,
BochaBaseURL: cfg.Tools.Web.Bocha.BaseURL,
BochaMaxResults: cfg.Tools.Web.Bocha.MaxResults,
BochaEnabled: cfg.Tools.Web.Bocha.Enabled,
Proxy: cfg.Tools.Web.Proxy,
}); searchTool != nil {
agent.Tools.Register(searchTool)
Expand Down
8 changes: 8 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,11 +450,19 @@ type PerplexityConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}

type BochaConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BOCHA_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BOCHA_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BOCHA_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BOCHA_MAX_RESULTS"`
}

type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
Bocha BochaConfig `json:"bocha"`
// 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"`
Expand Down
118 changes: 117 additions & 1 deletion pkg/tools/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,105 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
}

type BochaSearchProvider struct {
apiKey string
baseURL string
client *http.Client
}

func (p *BochaSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://api.bochaai.com/v1/web-search"
}

payload := map[string]any{
"query": query,
"summary": true,
"count": count,
}

payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(payloadBytes))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)

resp, err := p.client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bocha API error (status %d): %s", resp.StatusCode, string(body))
}

var searchResp struct {
Code int `json:"code"`
Data struct {
WebPages struct {
Value []struct {
Name string `json:"name"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Summary string `json:"summary"`
SiteName string `json:"siteName"`
DatePublished string `json:"datePublished"`
} `json:"value"`
} `json:"webPages"`
} `json:"data"`
Msg string `json:"msg"`
}

if err := json.Unmarshal(body, &searchResp); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}

if searchResp.Code != 200 {
return "", fmt.Errorf("bocha API error (code %d): %s", searchResp.Code, searchResp.Msg)
}

results := searchResp.Data.WebPages.Value
if len(results) == 0 {
return fmt.Sprintf("No results for: %s", query), nil
}

var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s (via Bocha)", query))

maxItems := min(len(results), count)
for i := 0; i < maxItems; i++ {
item := results[i]
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Name, item.URL))
// Prefer summary over snippet (summary is more complete)
desc := item.Summary
if desc == "" {
desc = item.Snippet
}
if desc != "" {
if len(desc) > 500 {
desc = desc[:500] + "..."
}
lines = append(lines, fmt.Sprintf(" %s", desc))
}
}

return strings.Join(lines, "\n"), nil
}

type WebSearchTool struct {
provider SearchProvider
maxResults int
Expand All @@ -412,14 +511,18 @@ type WebSearchToolOptions struct {
PerplexityAPIKey string
PerplexityMaxResults int
PerplexityEnabled bool
BochaAPIKey string
BochaBaseURL string
BochaMaxResults int
BochaEnabled bool
Proxy string
}

func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5

// Priority: Perplexity > Brave > Tavily > DuckDuckGo
// Priority: Perplexity > Brave > Tavily > DuckDuckGo > Bocha
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy}
if opts.PerplexityMaxResults > 0 {
Expand All @@ -444,6 +547,19 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
} else if opts.BochaEnabled && opts.BochaAPIKey != "" {
bochaClient, err := createHTTPClient(opts.Proxy, 15*time.Second)
if err != nil {
return nil
}
provider = &BochaSearchProvider{
apiKey: opts.BochaAPIKey,
baseURL: opts.BochaBaseURL,
client: bochaClient,
}
if opts.BochaMaxResults > 0 {
maxResults = opts.BochaMaxResults
}
} else {
return nil
}
Expand Down
110 changes: 110 additions & 0 deletions pkg/tools/web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,113 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
}
}

// TestWebTool_BochaSearch_Success verifies successful Bocha search
func TestWebTool_BochaSearch_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-bocha-key" {
t.Errorf("Expected Authorization Bearer test-bocha-key, got %s", r.Header.Get("Authorization"))
}

// Verify payload
var payload map[string]any
json.NewDecoder(r.Body).Decode(&payload)
if payload["query"] != "test query" {
t.Errorf("Expected query 'test query', got %v", payload["query"])
}

// Return mock Bocha response
response := map[string]any{
"code": 200,
"data": map[string]any{
"webPages": map[string]any{
"value": []map[string]any{
{
"name": "Bocha Result 1",
"url": "https://example.com/bocha/1",
"snippet": "Snippet for result 1",
"summary": "Summary for result 1",
},
{
"name": "Bocha Result 2",
"url": "https://example.com/bocha/2",
"snippet": "Snippet for result 2",
"summary": "",
},
},
},
},
"msg": "success",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}))
defer server.Close()

tool := NewWebSearchTool(WebSearchToolOptions{
BochaEnabled: true,
BochaAPIKey: "test-bocha-key",
BochaBaseURL: server.URL,
BochaMaxResults: 5,
})

ctx := context.Background()
result := tool.Execute(ctx, map[string]any{"query": "test query"})

if result.IsError {
t.Fatalf("Expected success, got IsError=true: %s", result.ForLLM)
}

// Should contain result titles and URLs
if !strings.Contains(result.ForUser, "Bocha Result 1") ||
!strings.Contains(result.ForUser, "https://example.com/bocha/1") {
t.Errorf("Expected results in output, got: %s", result.ForUser)
}

// Should prefer summary over snippet
if !strings.Contains(result.ForUser, "Summary for result 1") {
t.Errorf("Expected summary preferred over snippet, got: %s", result.ForUser)
}

// Should fall back to snippet when summary is empty
if !strings.Contains(result.ForUser, "Snippet for result 2") {
t.Errorf("Expected snippet fallback when summary empty, got: %s", result.ForUser)
}

// Should mention via Bocha
if !strings.Contains(result.ForUser, "via Bocha") {
t.Errorf("Expected 'via Bocha' in output, got: %s", result.ForUser)
}
}

// TestWebTool_BochaSearch_APIError verifies Bocha error handling
func TestWebTool_BochaSearch_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("unauthorized"))
}))
defer server.Close()

tool := NewWebSearchTool(WebSearchToolOptions{
BochaEnabled: true,
BochaAPIKey: "bad-key",
BochaBaseURL: server.URL,
})

ctx := context.Background()
result := tool.Execute(ctx, map[string]any{"query": "test"})

if !result.IsError {
t.Errorf("Expected error for unauthorized response")
}
if !strings.Contains(result.ForLLM, "bocha API error") {
t.Errorf("Expected bocha API error message, got: %s", result.ForLLM)
}
}