web_search.go

 1package tools
 2
 3import (
 4	"context"
 5	_ "embed"
 6	"html/template"
 7	"log/slog"
 8	"net/http"
 9	"time"
10
11	"charm.land/fantasy"
12)
13
14//go:embed web_search.md.tpl
15var webSearchDescriptionTmpl []byte
16
17var webSearchDescriptionTpl = template.Must(
18	template.New("webSearchDescription").
19		Parse(string(webSearchDescriptionTmpl)),
20)
21
22// NewWebSearchTool creates a web search tool for sub-agents (no permissions needed).
23func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
24	if client == nil {
25		transport := http.DefaultTransport.(*http.Transport).Clone()
26		transport.MaxIdleConns = 100
27		transport.MaxIdleConnsPerHost = 10
28		transport.IdleConnTimeout = 90 * time.Second
29
30		client = &http.Client{
31			Timeout:   30 * time.Second,
32			Transport: transport,
33		}
34	}
35
36	return fantasy.NewParallelAgentTool(
37		WebSearchToolName,
38		renderToolDescription(webSearchDescriptionTpl),
39		func(ctx context.Context, params WebSearchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
40			if params.Query == "" {
41				return fantasy.NewTextErrorResponse("query is required"), nil
42			}
43
44			maxResults := params.MaxResults
45			if maxResults <= 0 {
46				maxResults = 10
47			}
48			if maxResults > 20 {
49				maxResults = 20
50			}
51
52			maybeDelaySearch()
53			results, err := searchDuckDuckGo(ctx, client, params.Query, maxResults)
54			slog.Debug("Web search completed", "query", params.Query, "results", len(results), "err", err)
55			if err != nil {
56				return fantasy.NewTextErrorResponse("Failed to search: " + err.Error()), nil
57			}
58
59			return fantasy.NewTextResponse(formatSearchResults(results)), nil
60		},
61	)
62}