Merge pull request #78 from SatyamDevv/main

feat(search): Add DuckDuckGo fallback for credit-card-free web search
This commit is contained in:
lxowalle
2026-02-14 00:16:40 +08:00
committed by GitHub
5 changed files with 310 additions and 139 deletions

134
README.md
View File

@@ -68,12 +68,12 @@
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
| | OpenClaw | NanoBot | **PicoClaw** | | | OpenClaw | NanoBot | **PicoClaw** |
| --- | --- | --- |--- | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
| **Language** | TypeScript | Python | **Go** | | **Language** | TypeScript | Python | **Go** |
| **RAM** | >1GB |>100MB| **< 10MB** | | **RAM** | >1GB | >100MB | **< 10MB** |
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** | | **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
| **Cost** | Mac Mini 599$ | Most Linux SBC </br>~50$ |**Any Linux Board**</br>**As low as 10$** | | **Cost** | Mac Mini 599$ | Most Linux SBC </br>~50$ | **Any Linux Board**</br>**As low as 10$** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512"> <img src="assets/compare.jpg" alt="PicoClaw" width="512">
@@ -103,7 +103,7 @@
PicoClaw can be deployed on almost any Linux device! PicoClaw can be deployed on almost any Linux device!
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant - $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
@@ -180,7 +180,7 @@ docker compose --profile gateway up -d
> [!TIP] > [!TIP]
> Set your API key in `~/.picoclaw/config.json`. > Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) > Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize** **1. Initialize**
@@ -209,9 +209,14 @@ picoclaw onboard
}, },
"tools": { "tools": {
"web": { "web": {
"search": { "brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY", "api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5 "max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
} }
} }
} }
@@ -239,12 +244,12 @@ That's it! You have a working AI assistant in 2 minutes.
Talk to your picoclaw through Telegram, Discord, or DingTalk Talk to your picoclaw through Telegram, Discord, or DingTalk
| Channel | Setup | | Channel | Setup |
|---------|-------| | ------------ | -------------------------- |
| **Telegram** | Easy (just a token) | | **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) | | **Discord** | Easy (bot token + intents) |
| **QQ** | Easy (AppID + AppSecret) | | **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
@@ -400,8 +405,6 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** **Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
## ⚙️ Configuration ## ⚙️ Configuration
Config file: `~/.picoclaw/config.json` Config file: `~/.picoclaw/config.json`
@@ -598,15 +601,15 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE] > [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. > Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
|----------|---------|-------------| | -------------------------- | --------------------------------------- | ------------------------------------------------------ |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
<details> <details>
<summary><b>Zhipu</b></summary> <summary><b>Zhipu</b></summary>
@@ -632,8 +635,8 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
"zhipu": { "zhipu": {
"api_key": "Your API Key", "api_key": "Your API Key",
"api_base": "https://open.bigmodel.cn/api/paas/v4" "api_base": "https://open.bigmodel.cn/api/paas/v4"
}, }
}, }
} }
``` ```
@@ -694,8 +697,14 @@ picoclaw agent -m "Hello"
}, },
"tools": { "tools": {
"web": { "web": {
"search": { "brave": {
"api_key": "BSA..." "enabled": false,
"api_key": "BSA...",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
} }
} }
}, },
@@ -710,15 +719,15 @@ picoclaw agent -m "Hello"
## CLI Reference ## CLI Reference
| Command | Description | | Command | Description |
|---------|-------------| | ------------------------- | ----------------------------- |
| `picoclaw onboard` | Initialize config & workspace | | `picoclaw onboard` | Initialize config & workspace |
| `picoclaw agent -m "..."` | Chat with the agent | | `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode | | `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway | | `picoclaw gateway` | Start the gateway |
| `picoclaw status` | Show status | | `picoclaw status` | Show status |
| `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job | | `picoclaw cron add ...` | Add a scheduled job |
### Scheduled Tasks / Reminders ### Scheduled Tasks / Reminders
@@ -752,21 +761,28 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro
To enable web search: To enable web search:
1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) 1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
2. Add to `~/.picoclaw/config.json`: 2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
```json Add the key to `~/.picoclaw/config.json` if using Brave:
{
"tools": { ```json
"web": { {
"search": { "tools": {
"api_key": "YOUR_BRAVE_API_KEY", "web": {
"max_results": 5 "brave": {
} "enabled": false,
} "api_key": "YOUR_BRAVE_API_KEY",
} "max_results": 5
} },
``` "duckduckgo": {
"enabled": true,
"max_results": 5
}
}
}
}
```
### Getting content filtering errors ### Getting content filtering errors
@@ -780,9 +796,9 @@ This happens when another instance of the bot is running. Make sure only one `pi
## 📝 API Key Comparison ## 📝 API Key Comparison
| Service | Free Tier | Use Case | | Service | Free Tier | Use Case |
|---------|-----------|-----------| | ---------------- | ------------------- | ------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/month | Best for Chinese users | | **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | 2000 queries/month | Web search functionality | | **Brave Search** | 2000 queries/month | Web search functionality |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) |

View File

@@ -70,9 +70,15 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
// Shell execution // Shell execution
registry.Register(tools.NewExecTool(workspace, restrict)) registry.Register(tools.NewExecTool(workspace, restrict))
// Web tools if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
braveAPIKey := cfg.Tools.Web.Search.APIKey BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
registry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults)) BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
}); searchTool != nil {
registry.Register(searchTool)
}
registry.Register(tools.NewWebFetchTool(50000)) registry.Register(tools.NewWebFetchTool(50000))
// Message tool - available to both agent and subagent // Message tool - available to both agent and subagent

View File

@@ -164,13 +164,20 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
} }
type WebSearchConfig struct { type BraveConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
} }
type WebToolsConfig struct { type WebToolsConfig struct {
Search WebSearchConfig `json:"search"` Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
} }
type ToolsConfig struct { type ToolsConfig struct {
@@ -257,10 +264,15 @@ func DefaultConfig() *Config {
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{
Web: WebToolsConfig{ Web: WebToolsConfig{
Search: WebSearchConfig{ Brave: BraveConfig{
Enabled: false,
APIKey: "", APIKey: "",
MaxResults: 5, MaxResults: 5,
}, },
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
MaxResults: 5,
},
}, },
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{

View File

@@ -212,12 +212,17 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
if tools, ok := getMap(data, "tools"); ok { if tools, ok := getMap(data, "tools"); ok {
if web, ok := getMap(tools, "web"); ok { if web, ok := getMap(tools, "web"); ok {
// Migrate old "search" config to "brave" if api_key is present
if search, ok := getMap(web, "search"); ok { if search, ok := getMap(web, "search"); ok {
if v, ok := getString(search, "api_key"); ok { if v, ok := getString(search, "api_key"); ok {
cfg.Tools.Web.Search.APIKey = v cfg.Tools.Web.Brave.APIKey = v
if v != "" {
cfg.Tools.Web.Brave.Enabled = true
}
} }
if v, ok := getFloat(search, "max_results"); ok { if v, ok := getFloat(search, "max_results"); ok {
cfg.Tools.Web.Search.MaxResults = int(v) cfg.Tools.Web.Brave.MaxResults = int(v)
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
} }
} }
} }
@@ -271,8 +276,8 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
existing.Channels.MaixCam = incoming.Channels.MaixCam existing.Channels.MaixCam = incoming.Channels.MaixCam
} }
if existing.Tools.Web.Search.APIKey == "" { if existing.Tools.Web.Brave.APIKey == "" {
existing.Tools.Web.Search = incoming.Tools.Web.Search existing.Tools.Web.Brave = incoming.Tools.Web.Brave
} }
return existing return existing

View File

@@ -13,20 +13,210 @@ import (
) )
const ( const (
userAgent = "Mozilla/5.0 (compatible; picoclaw/1.0)" userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
) )
type SearchProvider interface {
Search(ctx context.Context, query string, count int) (string, error)
}
type BraveSearchProvider struct {
apiKey string
}
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var searchResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &searchResp); err != nil {
// Log error body for debugging
fmt.Printf("Brave API Error Body: %s\n", string(body))
return "", fmt.Errorf("failed to parse response: %w", err)
}
results := searchResp.Web.Results
if len(results) == 0 {
return fmt.Sprintf("No results for: %s", query), nil
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s", query))
for i, item := range results {
if i >= count {
break
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
if item.Description != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Description))
}
}
return strings.Join(lines, "\n"), nil
}
type DuckDuckGoSearchProvider struct{}
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return p.extractResults(string(body), count, query)
}
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
// Try finding the result links directly first, as they are the most critical
// Pattern: <a class="result__a" href="...">Title</a>
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
reLink := regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
matches := reLink.FindAllStringSubmatch(html, count+5)
if len(matches) == 0 {
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query))
// Pre-compile snippet regex to run inside the loop
// We'll search for snippets relative to the link position or just globally if needed
// But simple global search for snippets might mismatch order.
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
// Or better: Let's assume the snippet follows the link in the HTML
// A better regex approach: iterate through text and find matches in order
// But for now, let's grab all snippets too
reSnippet := regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
maxItems := min(len(matches), count)
for i := 0; i < maxItems; i++ {
urlStr := matches[i][1]
title := stripTags(matches[i][2])
title = strings.TrimSpace(title)
// URL decoding if needed
if strings.Contains(urlStr, "uddg=") {
if u, err := url.QueryUnescape(urlStr); err == nil {
idx := strings.Index(u, "uddg=")
if idx != -1 {
urlStr = u[idx+5:]
}
}
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
// Attempt to attach snippet if available and index aligns
if i < len(snippetMatches) {
snippet := stripTags(snippetMatches[i][1])
snippet = strings.TrimSpace(snippet)
if snippet != "" {
lines = append(lines, fmt.Sprintf(" %s", snippet))
}
}
}
return strings.Join(lines, "\n"), nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func stripTags(content string) string {
re := regexp.MustCompile(`<[^>]+>`)
return re.ReplaceAllString(content, "")
}
type WebSearchTool struct { type WebSearchTool struct {
apiKey string provider SearchProvider
maxResults int maxResults int
} }
func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool { type WebSearchToolOptions struct {
if maxResults <= 0 || maxResults > 10 { BraveAPIKey string
maxResults = 5 BraveMaxResults int
BraveEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
}
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
// Priority: Brave > DuckDuckGo
if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
}
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
} else {
return nil
} }
return &WebSearchTool{ return &WebSearchTool{
apiKey: apiKey, provider: provider,
maxResults: maxResults, maxResults: maxResults,
} }
} }
@@ -59,10 +249,6 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
} }
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
if t.apiKey == "" {
return ErrorResult("BRAVE_API_KEY not configured")
}
query, ok := args["query"].(string) query, ok := args["query"].(string)
if !ok { if !ok {
return ErrorResult("query is required") return ErrorResult("query is required")
@@ -75,68 +261,14 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
} }
} }
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", result, err := t.provider.Search(ctx, query, count)
url.QueryEscape(query), count)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err)) return ErrorResult(fmt.Sprintf("search failed: %v", err))
} }
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", t.apiKey)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
}
var searchResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &searchResp); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}
results := searchResp.Web.Results
if len(results) == 0 {
msg := fmt.Sprintf("No results for: %s", query)
return &ToolResult{
ForLLM: msg,
ForUser: msg,
}
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s", query))
for i, item := range results {
if i >= count {
break
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
if item.Description != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Description))
}
}
output := strings.Join(lines, "\n")
return &ToolResult{ return &ToolResult{
ForLLM: fmt.Sprintf("Found %d results for: %s", len(results), query), ForLLM: result,
ForUser: output, ForUser: result,
} }
} }