From 48c04e050d93ed585162ca3ce96dd056e610389b Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 26 Mar 2026 21:02:46 +0100 Subject: [PATCH 1/3] feat(tools) range in web_search --- docs/tools_configuration.md | 30 ++++- pkg/config/config_test.go | 8 +- pkg/tools/web.go | 205 +++++++++++++++++++++++++++---- pkg/tools/web_test.go | 239 ++++++++++++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 31 deletions(-) diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index b5907b991..829c9d370 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -98,7 +98,7 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `enabled` | bool | false | Enable Perplexity search | | `api_key` | string | - | Perplexity API key | | `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | -| `max_results` | int | 5 | Maximum number of results | +| `max_results` | int | 10 | Maximum number of results | ### Tavily @@ -107,7 +107,7 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `enabled` | bool | false | Enable Tavily search | | `api_key` | string | - | Tavily API key | | `base_url` | string | - | Custom Tavily API base URL | -| `max_results` | int | 0 | Maximum number of results (0 = default) | +| `max_results` | int | 10 | Maximum number of results | ### SearXNG @@ -115,7 +115,7 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa |---------------|--------|--------------------------|---------------------------| | `enabled` | bool | false | Enable SearXNG search | | `base_url` | string | `http://localhost:8888` | SearXNG instance URL | -| `max_results` | int | 5 | Maximum number of results | +| `max_results` | int | 10 | Maximum number of results | ### GLM Search @@ -125,7 +125,7 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `api_key` | string | - | GLM API key | | `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | | `search_engine` | string | `search_std` | Search engine type | -| `max_results` | int | 5 | Maximum number of results | +| `max_results` | int | 10 | Maximum number of results | ### Additional Web Settings @@ -134,6 +134,28 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `prefer_native` | bool | true | Prefer provider's native search over configured search engines | | `private_host_whitelist` | string[] | `[]` | Private/internal hosts allowed for web fetching | +### `web_search` Tool Parameters + +At runtime, the `web_search` tool accepts the following parameters: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `query` | string | yes | Search query string | +| `count` | integer | no | Number of results to return. Default: `10`, max: `10` | +| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) | + +If `range` is omitted, PicoClaw performs an unrestricted search. + +### Example `web_search` Call + +```json +{ + "query": "ai agent news", + "count": 10, + "range": "w" +} +``` + ## Exec Tool The exec tool is used to execute shell commands. diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 6718de91e..e60b2440e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -306,14 +306,14 @@ func TestDefaultConfig_WebTools(t *testing.T) { cfg := DefaultConfig() // Verify web tools defaults - if cfg.Tools.Web.Brave.MaxResults != 5 { - t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) + if cfg.Tools.Web.Brave.MaxResults != 10 { + t.Error("Expected Brave MaxResults 10, got ", cfg.Tools.Web.Brave.MaxResults) } if len(cfg.Tools.Web.Brave.APIKeys()) != 0 { t.Error("Brave API key should be empty by default") } - if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { - t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) + if cfg.Tools.Web.DuckDuckGo.MaxResults != 10 { + t.Error("Expected DuckDuckGo MaxResults 10, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) } } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 7ff724802..febb60a6c 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -86,7 +86,122 @@ func (it *APIKeyIterator) Next() (string, bool) { } type SearchProvider interface { - Search(ctx context.Context, query string, count int) (string, error) + Search(ctx context.Context, query string, count int, rangeCode string) (string, error) +} + +func normalizeSearchRange(raw string) (string, error) { + rangeCode := strings.ToLower(strings.TrimSpace(raw)) + switch rangeCode { + case "", "d", "w", "m", "y": + return rangeCode, nil + default: + return "", fmt.Errorf("range must be one of: d, w, m, y") + } +} + +func mapBraveFreshness(rangeCode string) string { + switch rangeCode { + case "d": + return "pd" + case "w": + return "pw" + case "m": + return "pm" + case "y": + return "py" + default: + return "" + } +} + +func mapTavilyTimeRange(rangeCode string) string { + switch rangeCode { + case "d": + return "day" + case "w": + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +func mapPerplexityRecencyFilter(rangeCode string) string { + switch rangeCode { + case "d": + return "day" + case "w": + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +func mapDuckDuckGoDateFilter(rangeCode string) string { + switch rangeCode { + case "d": + return "d" + case "w": + return "w" + case "m": + return "m" + case "y": + return "t" + default: + return "" + } +} + +func mapSearXNGTimeRange(rangeCode string) string { + switch rangeCode { + case "d": + return "day" + case "w": + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +func mapGLMRecencyFilter(rangeCode string) string { + switch rangeCode { + case "d": + return "oneDay" + case "w": + return "oneWeek" + case "m": + return "oneMonth" + case "y": + return "oneYear" + default: + return "noLimit" + } +} + +func mapBaiduRecencyFilter(rangeCode string) string { + switch rangeCode { + case "d", "w": + // Baidu does not expose a day-level filter. Use the closest supported + // window to keep recency bias instead of silently dropping the filter. + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } } type BraveSearchProvider struct { @@ -95,9 +210,12 @@ type BraveSearchProvider struct { client *http.Client } -func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", url.QueryEscape(query), count) + if freshness := mapBraveFreshness(rangeCode); freshness != "" { + searchURL += "&freshness=" + url.QueryEscape(freshness) + } var lastErr error iter := p.keyPool.NewIterator() @@ -186,7 +304,7 @@ type TavilySearchProvider struct { client *http.Client } -func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://api.tavily.com/search" @@ -210,6 +328,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i "include_raw_content": false, "max_results": count, } + if timeRange := mapTavilyTimeRange(rangeCode); timeRange != "" { + payload["time_range"] = timeRange + } bodyBytes, err := json.Marshal(payload) if err != nil { @@ -289,8 +410,11 @@ type DuckDuckGoSearchProvider struct { client *http.Client } -func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) + if dateFilter := mapDuckDuckGoDateFilter(rangeCode); dateFilter != "" { + searchURL += "&df=" + url.QueryEscape(dateFilter) + } req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { @@ -381,7 +505,7 @@ type PerplexitySearchProvider struct { client *http.Client } -func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := "https://api.perplexity.ai/chat/completions" var lastErr error @@ -407,6 +531,9 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou }, "max_tokens": 1000, } + if recencyFilter := mapPerplexityRecencyFilter(rangeCode); recencyFilter != "" { + payload["search_recency_filter"] = recencyFilter + } payloadBytes, err := json.Marshal(payload) if err != nil { @@ -473,10 +600,13 @@ type SearXNGSearchProvider struct { baseURL string } -func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", strings.TrimSuffix(p.baseURL, "/"), url.QueryEscape(query)) + if timeRange := mapSearXNGTimeRange(rangeCode); timeRange != "" { + searchURL += "&time_range=" + url.QueryEscape(timeRange) + } req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { @@ -539,7 +669,7 @@ type GLMSearchProvider struct { client *http.Client } -func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" @@ -552,6 +682,9 @@ func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) "count": count, "content_size": "medium", } + if recencyFilter := mapGLMRecencyFilter(rangeCode); recencyFilter != "" { + payload["search_recency_filter"] = recencyFilter + } bodyBytes, err := json.Marshal(payload) if err != nil { @@ -620,7 +753,7 @@ type BaiduSearchProvider struct { client *http.Client } -func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { +func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search" @@ -636,6 +769,9 @@ func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count in "search_source": "baidu_search_v2", "resource_type_filter": []map[string]any{{"type": "web", "top_k": count}}, } + if recencyFilter := mapBaiduRecencyFilter(rangeCode); recencyFilter != "" { + payload["search_recency_filter"] = recencyFilter + } bodyBytes, err := json.Marshal(payload) if err != nil { @@ -729,7 +865,7 @@ type WebSearchToolOptions struct { func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider - maxResults := 5 + maxResults := 10 // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 { client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) @@ -742,7 +878,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { client: client, } if opts.PerplexityMaxResults > 0 { - maxResults = opts.PerplexityMaxResults + maxResults = min(opts.PerplexityMaxResults, 10) } } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -751,12 +887,12 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { } provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client} if opts.BraveMaxResults > 0 { - maxResults = opts.BraveMaxResults + maxResults = min(opts.BraveMaxResults, 10) } } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} if opts.SearXNGMaxResults > 0 { - maxResults = opts.SearXNGMaxResults + maxResults = min(opts.SearXNGMaxResults, 10) } } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -770,7 +906,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { client: client, } if opts.TavilyMaxResults > 0 { - maxResults = opts.TavilyMaxResults + maxResults = min(opts.TavilyMaxResults, 10) } } else if opts.DuckDuckGoEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -779,7 +915,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { } provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} if opts.DuckDuckGoMaxResults > 0 { - maxResults = opts.DuckDuckGoMaxResults + maxResults = min(opts.DuckDuckGoMaxResults, 10) } } else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" { client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) @@ -793,7 +929,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { client: client, } if opts.BaiduSearchMaxResults > 0 { - maxResults = opts.BaiduSearchMaxResults + maxResults = min(opts.BaiduSearchMaxResults, 10) } } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -812,7 +948,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { client: client, } if opts.GLMSearchMaxResults > 0 { - maxResults = opts.GLMSearchMaxResults + maxResults = min(opts.GLMSearchMaxResults, 10) } } else { return nil, nil @@ -829,7 +965,7 @@ func (t *WebSearchTool) Name() string { } func (t *WebSearchTool) Description() string { - return "Search the web for current information. Returns titles, URLs, and snippets from search results." + return "Search the web for current information. Supports query, count, and an optional temporal range filter. Returns titles, URLs, and snippets from search results." } func (t *WebSearchTool) Parameters() map[string]any { @@ -842,10 +978,15 @@ func (t *WebSearchTool) Parameters() map[string]any { }, "count": map[string]any{ "type": "integer", - "description": "Number of results (1-10)", + "description": "Number of results (default: 10, max: 10)", "minimum": 1.0, "maximum": 10.0, }, + "range": map[string]any{ + "type": "string", + "description": "Optional time filter: d (day), w (week), m (month), y (year)", + "enum": []string{"d", "w", "m", "y"}, + }, }, "required": []string{"query"}, } @@ -853,18 +994,36 @@ func (t *WebSearchTool) Parameters() map[string]any { func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) - if !ok { + if !ok || strings.TrimSpace(query) == "" { return ErrorResult("query is required") } + query = strings.TrimSpace(query) + count64, err := getInt64Arg(args, "count", int64(t.maxResults)) + if err != nil { + return ErrorResult(err.Error()) + } count := t.maxResults - if c, ok := args["count"].(float64); ok { - if int(c) > 0 && int(c) <= 10 { - count = int(c) + if count64 > 0 && count64 <= 10 { + count = int(count64) + } + + rangeCode, err := normalizeSearchRange("") + if err != nil { + return ErrorResult(err.Error()) + } + if rawRange, exists := args["range"]; exists { + rangeStr, ok := rawRange.(string) + if !ok { + return ErrorResult("range must be a string") + } + rangeCode, err = normalizeSearchRange(rangeStr) + if err != nil { + return ErrorResult(err.Error()) } } - result, err := t.provider.Search(ctx, query, count) + result, err := t.provider.Search(ctx, query, count, rangeCode) if err != nil { return ErrorResult(fmt.Sprintf("search failed: %v", err)) } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 98c763193..de6187cfa 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -426,6 +426,96 @@ func TestWebTool_WebSearch_MissingQuery(t *testing.T) { } } +func TestNormalizeSearchRange(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "empty", input: "", want: ""}, + {name: "day", input: "d", want: "d"}, + {name: "week uppercase trimmed", input: " W ", want: "w"}, + {name: "month", input: "m", want: "m"}, + {name: "year", input: "y", want: "y"}, + {name: "invalid", input: "q", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeSearchRange(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("normalizeSearchRange(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSearchRangeMappings(t *testing.T) { + if got := mapBraveFreshness("d"); got != "pd" { + t.Fatalf("mapBraveFreshness(d) = %q, want pd", got) + } + if got := mapBraveFreshness("y"); got != "py" { + t.Fatalf("mapBraveFreshness(y) = %q, want py", got) + } + if got := mapTavilyTimeRange("w"); got != "week" { + t.Fatalf("mapTavilyTimeRange(w) = %q, want week", got) + } + if got := mapPerplexityRecencyFilter("m"); got != "month" { + t.Fatalf("mapPerplexityRecencyFilter(m) = %q, want month", got) + } + if got := mapDuckDuckGoDateFilter("y"); got != "t" { + t.Fatalf("mapDuckDuckGoDateFilter(y) = %q, want t", got) + } + if got := mapSearXNGTimeRange("d"); got != "day" { + t.Fatalf("mapSearXNGTimeRange(d) = %q, want day", got) + } + if got := mapGLMRecencyFilter("w"); got != "oneWeek" { + t.Fatalf("mapGLMRecencyFilter(w) = %q, want oneWeek", got) + } + if got := mapGLMRecencyFilter(""); got != "noLimit" { + t.Fatalf("mapGLMRecencyFilter(\"\") = %q, want noLimit", got) + } + if got := mapBaiduRecencyFilter("d"); got != "week" { + t.Fatalf("mapBaiduRecencyFilter(d) = %q, want week", got) + } + if got := mapBaiduRecencyFilter("m"); got != "month" { + t.Fatalf("mapBaiduRecencyFilter(m) = %q, want month", got) + } +} + +func TestWebTool_WebSearch_InvalidRange(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BraveEnabled: true, + BraveAPIKeys: []string{"test-key"}, + BraveMaxResults: 5, + }) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "invalid", + }) + + if !result.IsError { + t.Fatalf("expected invalid range to return error") + } + if !strings.Contains(result.ForLLM, "range must be one of: d, w, m, y") { + t.Fatalf("unexpected error message: %q", result.ForLLM) + } +} + // TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { withPrivateWebFetchHostsAllowed(t) @@ -1069,6 +1159,45 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { } } +func TestWebTool_TavilySearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["time_range"] != "week" { + t.Fatalf("expected time_range=week, got %v", payload["time_range"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"title": "Recent result", "url": "https://example.com/recent", "content": "snippet"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKeys: []string{"test-key"}, + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "w", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + // TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA verifies that a 403 response // with cf-mitigated: challenge triggers a retry using the honest picoclaw User-Agent, // and that the retry response is returned when it succeeds. @@ -1297,6 +1426,39 @@ func TestWebTool_TavilySearch_Failover(t *testing.T) { } } +func TestWebTool_SearXNGSearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("time_range"); got != "year" { + t.Fatalf("expected time_range=year, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"title": "Recent result", "url": "https://example.com/1", "content": "snippet"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + SearXNGEnabled: true, + SearXNGBaseURL: server.URL, + SearXNGMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "y", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + func TestWebTool_GLMSearch_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { @@ -1365,6 +1527,83 @@ func TestWebTool_GLMSearch_Success(t *testing.T) { } } +func TestWebTool_GLMSearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["search_recency_filter"] != "oneMonth" { + t.Fatalf("expected search_recency_filter=oneMonth, got %v", payload["search_recency_filter"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "search_result": []map[string]any{ + {"title": "Recent GLM Result", "content": "snippet", "link": "https://example.com/glm-range"}, + }, + }) + })) + 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", + "range": "m", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + +func TestWebTool_BaiduSearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["search_recency_filter"] != "week" { + t.Fatalf("expected search_recency_filter=week for day fallback, got %v", payload["search_recency_filter"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "references": []map[string]any{ + {"title": "Recent Baidu Result", "url": "https://example.com/baidu", "content": "snippet"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BaiduSearchEnabled: true, + BaiduSearchAPIKey: "test-baidu-key", + BaiduSearchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "d", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + func TestWebTool_GLMSearch_APIError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) From b7f6ab7176ebaaf0c7e9bd84b87ea9a5caae9a9b Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 26 Mar 2026 21:27:35 +0100 Subject: [PATCH 2/3] fix conf --- docs/tools_configuration.md | 40 ++++++++++++++++++------------------- pkg/config/config_test.go | 8 ++++---- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index 829c9d370..314cbd38f 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -70,12 +70,12 @@ General settings for fetching and processing webpage content. Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries. -| Config | Type | Default | Description | -|---------------|--------|------------------------------------------------------------------|---------------------------| -| `enabled` | bool | false | Enable Baidu Search | -| `api_key` | string | - | Qianfan API key | -| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL | -| `max_results` | int | 10 | Maximum number of results | +| Config | Type | Default | Description | +|---------------|--------|--------------------------------------------------------|---------------------------| +| `enabled` | bool | false | Enable Baidu Search | +| `api_key` | string | - | Qianfan API key | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL | +| `max_results` | int | 5 | Maximum number of results | ```json { @@ -98,7 +98,7 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `enabled` | bool | false | Enable Perplexity search | | `api_key` | string | - | Perplexity API key | | `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | -| `max_results` | int | 10 | Maximum number of results | +| `max_results` | int | 5 | Maximum number of results | ### Tavily @@ -107,25 +107,25 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `enabled` | bool | false | Enable Tavily search | | `api_key` | string | - | Tavily API key | | `base_url` | string | - | Custom Tavily API base URL | -| `max_results` | int | 10 | Maximum number of results | +| `max_results` | int | 5 | Maximum number of results | ### SearXNG -| Config | Type | Default | Description | -|---------------|--------|--------------------------|---------------------------| -| `enabled` | bool | false | Enable SearXNG search | -| `base_url` | string | `http://localhost:8888` | SearXNG instance URL | -| `max_results` | int | 10 | Maximum number of results | +| Config | Type | Default | Description | +|---------------|--------|-------------------------|---------------------------| +| `enabled` | bool | false | Enable SearXNG search | +| `base_url` | string | `http://localhost:8888` | SearXNG instance URL | +| `max_results` | int | 5 | Maximum number of results | ### GLM Search -| Config | Type | Default | Description | -|-----------------|--------|------------------------------------------------------|---------------------------| -| `enabled` | bool | false | Enable GLM Search | -| `api_key` | string | - | GLM API key | -| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | -| `search_engine` | string | `search_std` | Search engine type | -| `max_results` | int | 10 | Maximum number of results | +| Config | Type | Default | Description | +|-----------------|--------|---------------------------------------------------|---------------------------| +| `enabled` | bool | false | Enable GLM Search | +| `api_key` | string | - | GLM API key | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | +| `search_engine` | string | `search_std` | Search engine type | +| `max_results` | int | 5 | Maximum number of results | ### Additional Web Settings diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e60b2440e..6718de91e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -306,14 +306,14 @@ func TestDefaultConfig_WebTools(t *testing.T) { cfg := DefaultConfig() // Verify web tools defaults - if cfg.Tools.Web.Brave.MaxResults != 10 { - t.Error("Expected Brave MaxResults 10, got ", cfg.Tools.Web.Brave.MaxResults) + if cfg.Tools.Web.Brave.MaxResults != 5 { + t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } if len(cfg.Tools.Web.Brave.APIKeys()) != 0 { t.Error("Brave API key should be empty by default") } - if cfg.Tools.Web.DuckDuckGo.MaxResults != 10 { - t.Error("Expected DuckDuckGo MaxResults 10, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) + if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { + t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) } } From e2018c4aa75f79fe818c288e320b18e67387ea94 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 26 Mar 2026 21:33:43 +0100 Subject: [PATCH 3/3] fix lint --- pkg/tools/web.go | 110 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 18 deletions(-) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index febb60a6c..342f7458b 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -43,7 +43,9 @@ var ( reBlankLines = regexp.MustCompile(`\n{3,}`) // DuckDuckGo result extraction - reDDGLink = regexp.MustCompile(`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`) + reDDGLink = regexp.MustCompile( + `]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`, + ) reDDGSnippet = regexp.MustCompile(`([\s\S]*?)`) ) @@ -210,7 +212,12 @@ type BraveSearchProvider struct { client *http.Client } -func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *BraveSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", url.QueryEscape(query), count) if freshness := mapBraveFreshness(rangeCode); freshness != "" { @@ -304,7 +311,12 @@ type TavilySearchProvider struct { client *http.Client } -func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *TavilySearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://api.tavily.com/search" @@ -410,7 +422,12 @@ type DuckDuckGoSearchProvider struct { client *http.Client } -func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *DuckDuckGoSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) if dateFilter := mapDuckDuckGoDateFilter(rangeCode); dateFilter != "" { searchURL += "&df=" + url.QueryEscape(dateFilter) @@ -437,7 +454,11 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou return p.extractResults(string(body), count, query) } -func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { +func (p *DuckDuckGoSearchProvider) extractResults( + html string, + count int, + query string, +) (string, error) { // Simple regex based extraction for DDG HTML // Strategy: Find all result containers or key anchors directly @@ -505,7 +526,12 @@ type PerplexitySearchProvider struct { client *http.Client } -func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *PerplexitySearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := "https://api.perplexity.ai/chat/completions" var lastErr error @@ -525,8 +551,12 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.", }, { - "role": "user", - "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count), + "role": "user", + "content": fmt.Sprintf( + "Search for: %s. Provide up to %d relevant results.", + query, + count, + ), }, }, "max_tokens": 1000, @@ -540,7 +570,12 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou return "", fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) + req, err := http.NewRequestWithContext( + ctx, + "POST", + searchURL, + strings.NewReader(string(payloadBytes)), + ) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } @@ -590,7 +625,11 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou return fmt.Sprintf("No results for: %s", query), nil } - return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil + return fmt.Sprintf( + "Results for: %s (via Perplexity)\n%s", + query, + searchResp.Choices[0].Message.Content, + ), nil } return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) @@ -600,7 +639,12 @@ type SearXNGSearchProvider struct { baseURL string } -func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *SearXNGSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", strings.TrimSuffix(p.baseURL, "/"), url.QueryEscape(query)) @@ -669,7 +713,12 @@ type GLMSearchProvider struct { client *http.Client } -func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *GLMSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" @@ -753,7 +802,12 @@ type BaiduSearchProvider struct { client *http.Client } -func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count int, rangeCode string) (string, error) { +func (p *BaiduSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { searchURL := p.baseURL if searchURL == "" { searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search" @@ -1197,7 +1251,12 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { - return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes)) + return ErrorResult( + fmt.Sprintf( + "failed to read response: size exceeded %d bytes limit", + t.fetchLimitBytes, + ), + ) } return ErrorResult(err.Error()) } @@ -1247,7 +1306,11 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe // If the charset is not utf-8, we might have to convert the bodyStr // before passing it to the HTML/Markdown parser if strings.ToLower(charset) != "utf-8" { - logger.WarnCF("tool", "Note: the content is not in UTF-8", map[string]any{"charset": charset}) + logger.WarnCF( + "tool", + "Note: the content is not in UTF-8", + map[string]any{"charset": charset}, + ) } } @@ -1391,7 +1454,11 @@ func newSafeDialContext( continue } attempted++ - conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port)) + conn, err := dialer.DialContext( + ctx, + network, + net.JoinHostPort(ipAddr.IP.String(), port), + ) if err == nil { return conn, nil } @@ -1399,10 +1466,17 @@ func newSafeDialContext( } if attempted == 0 { - return nil, fmt.Errorf("all resolved addresses for %s are private, restricted, or not whitelisted", host) + return nil, fmt.Errorf( + "all resolved addresses for %s are private, restricted, or not whitelisted", + host, + ) } if lastErr != nil { - return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr) + return nil, fmt.Errorf( + "failed connecting to public addresses for %s: %w", + host, + lastErr, + ) } return nil, fmt.Errorf("failed connecting to public addresses for %s", host) }