mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(tools) range in web_search
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+182
-23
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user