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}