2026-02-04 11:06:13 +00:00
package tools
import (
2026-02-22 16:30:14 +00:00
"bytes"
2026-02-04 11:06:13 +00:00
"context"
"encoding/json"
2026-02-27 17:56:02 +00:00
"errors"
2026-02-04 11:06:13 +00:00
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const (
2026-02-12 16:18:51 +00:00
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
2026-03-01 05:55:46 +00:00
// HTTP client timeouts for web tool providers.
searchTimeout = 10 * time . Second // Brave, Tavily, DuckDuckGo
perplexityTimeout = 30 * time . Second // Perplexity (LLM-based, slower)
fetchTimeout = 60 * time . Second // WebFetchTool
defaultMaxChars = 50000
maxRedirects = 5
2026-02-04 11:06:13 +00:00
)
2026-02-26 09:44:03 +00:00
// Pre-compiled regexes for HTML text extraction
var (
reScript = regexp . MustCompile ( ` <script[\s\S]*?</script> ` )
reStyle = regexp . MustCompile ( ` <style[\s\S]*?</style> ` )
reTags = regexp . MustCompile ( ` <[^>]+> ` )
reWhitespace = regexp . MustCompile ( ` [^\S\n]+ ` )
reBlankLines = regexp . MustCompile ( ` \n { 3,} ` )
// DuckDuckGo result extraction
reDDGLink = regexp . MustCompile ( ` <a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a> ` )
reDDGSnippet = regexp . MustCompile ( ` <a class="result__snippet[^"]*".*?>([\s\S]*?)</a> ` )
2026-02-04 11:06:13 +00:00
)
2026-02-24 09:16:16 +00:00
// createHTTPClient creates an HTTP client with optional proxy support
func createHTTPClient ( proxyURL string , timeout time . Duration ) ( * http . Client , error ) {
client := & http . Client {
Timeout : timeout ,
Transport : & http . Transport {
MaxIdleConns : 10 ,
IdleConnTimeout : 30 * time . Second ,
DisableCompression : false ,
TLSHandshakeTimeout : 15 * time . Second ,
} ,
}
if proxyURL != "" {
proxy , err := url . Parse ( proxyURL )
if err != nil {
return nil , fmt . Errorf ( "invalid proxy URL: %w" , err )
}
scheme := strings . ToLower ( proxy . Scheme )
switch scheme {
case "http" , "https" , "socks5" , "socks5h" :
default :
return nil , fmt . Errorf (
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)" ,
proxy . Scheme ,
)
}
if proxy . Host == "" {
return nil , fmt . Errorf ( "invalid proxy URL: missing host" )
}
client . Transport . ( * http . Transport ) . Proxy = http . ProxyURL ( proxy )
} else {
client . Transport . ( * http . Transport ) . Proxy = http . ProxyFromEnvironment
}
return client , nil
}
2026-02-12 16:18:51 +00:00
type SearchProvider interface {
Search ( ctx context . Context , query string , count int ) ( string , error )
2026-02-04 11:06:13 +00:00
}
2026-02-12 16:18:51 +00:00
type BraveSearchProvider struct {
apiKey string
2026-02-24 09:16:16 +00:00
proxy string
2026-03-01 05:55:46 +00:00
client * http . Client
2026-02-04 11:06:13 +00:00
}
2026-02-12 16:18:51 +00:00
func ( p * BraveSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
2026-02-04 11:06:13 +00:00
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" )
2026-02-12 16:18:51 +00:00
req . Header . Set ( "X-Subscription-Token" , p . apiKey )
2026-02-04 11:06:13 +00:00
2026-03-01 05:55:46 +00:00
resp , err := p . client . Do ( req )
2026-02-04 11:06:13 +00:00
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 )
}
2026-03-03 09:50:29 +00:00
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "brave api error (status %d): %s" , resp . StatusCode , string ( body ) )
}
2026-02-04 11:06:13 +00:00
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 {
2026-02-12 16:18:51 +00:00
// Log error body for debugging
fmt . Printf ( "Brave API Error Body: %s\n" , string ( body ) )
2026-02-04 11:06:13 +00:00
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
}
2026-02-22 16:30:14 +00:00
type TavilySearchProvider struct {
apiKey string
baseURL string
2026-02-23 08:23:10 +00:00
proxy string
2026-03-01 05:55:46 +00:00
client * http . Client
2026-02-22 16:30:14 +00:00
}
func ( p * TavilySearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := p . baseURL
if searchURL == "" {
searchURL = "https://api.tavily.com/search"
}
payload := map [ string ] any {
"api_key" : p . apiKey ,
"query" : query ,
"search_depth" : "advanced" ,
"include_answer" : false ,
"include_images" : false ,
2026-02-23 04:12:34 +00:00
"include_raw_content" : false ,
2026-02-22 16:30:14 +00:00
"max_results" : count ,
}
bodyBytes , err := json . Marshal ( payload )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal payload: %w" , err )
}
req , err := http . NewRequestWithContext ( ctx , "POST" , searchURL , bytes . NewBuffer ( bodyBytes ) )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "User-Agent" , userAgent )
2026-03-01 05:55:46 +00:00
resp , err := p . client . Do ( req )
2026-02-22 16:30:14 +00:00
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 )
}
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "tavily api error (status %d): %s" , resp . StatusCode , string ( body ) )
}
var searchResp struct {
Results [ ] struct {
Title string ` json:"title" `
URL string ` json:"url" `
Content string ` json:"content" `
} ` json:"results" `
}
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
results := searchResp . 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 (via Tavily)" , 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 . Content != "" {
lines = append ( lines , fmt . Sprintf ( " %s" , item . Content ) )
}
}
return strings . Join ( lines , "\n" ) , nil
}
2026-02-24 09:16:16 +00:00
type DuckDuckGoSearchProvider struct {
2026-03-01 05:55:46 +00:00
proxy string
client * http . Client
2026-02-24 09:16:16 +00:00
}
2026-02-12 16:18:51 +00:00
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 )
2026-03-01 05:55:46 +00:00
resp , err := p . client . Do ( req )
2026-02-12 16:18:51 +00:00
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
2026-02-14 01:47:55 +00:00
2026-02-12 16:18:51 +00:00
// 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
2026-02-26 09:44:03 +00:00
matches := reDDGLink . FindAllStringSubmatch ( html , count + 5 )
2026-02-12 16:18:51 +00:00
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
2026-02-14 01:47:55 +00:00
2026-02-12 16:18:51 +00:00
// A better regex approach: iterate through text and find matches in order
// But for now, let's grab all snippets too
2026-02-26 09:44:03 +00:00
snippetMatches := reDDGSnippet . FindAllStringSubmatch ( html , count + 5 )
2026-02-12 16:18:51 +00:00
maxItems := min ( len ( matches ) , count )
2026-02-14 01:47:55 +00:00
2026-02-27 08:35:07 +00:00
for i := range maxItems {
2026-02-12 16:18:51 +00:00
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 {
2026-02-27 08:35:07 +00:00
_ , after , ok := strings . Cut ( u , "uddg=" )
if ok {
urlStr = after
2026-02-12 16:18:51 +00:00
}
}
}
lines = append ( lines , fmt . Sprintf ( "%d. %s\n %s" , i + 1 , title , urlStr ) )
2026-02-14 01:47:55 +00:00
2026-02-12 16:18:51 +00:00
// 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 stripTags ( content string ) string {
2026-02-26 09:44:03 +00:00
return reTags . ReplaceAllString ( content , "" )
2026-02-12 16:18:51 +00:00
}
2026-02-17 13:02:56 +00:00
type PerplexitySearchProvider struct {
apiKey string
2026-02-24 09:16:16 +00:00
proxy string
2026-03-01 05:55:46 +00:00
client * http . Client
2026-02-17 13:02:56 +00:00
}
func ( p * PerplexitySearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := "https://api.perplexity.ai/chat/completions"
2026-02-18 19:48:23 +00:00
payload := map [ string ] any {
2026-02-17 13:02:56 +00:00
"model" : "sonar" ,
"messages" : [ ] map [ string ] string {
2026-02-18 19:48:23 +00:00
{
"role" : "system" ,
"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 ) ,
} ,
2026-02-17 13:02:56 +00:00
} ,
"max_tokens" : 1000 ,
}
payloadBytes , err := json . Marshal ( payload )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal request: %w" , err )
}
req , err := http . NewRequestWithContext ( ctx , "POST" , searchURL , strings . NewReader ( string ( payloadBytes ) ) )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "Authorization" , "Bearer " + p . apiKey )
req . Header . Set ( "User-Agent" , userAgent )
2026-03-01 05:55:46 +00:00
resp , err := p . client . Do ( req )
2026-02-17 13:02:56 +00:00
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 )
}
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "Perplexity API error: %s" , string ( body ) )
}
var searchResp struct {
Choices [ ] struct {
Message struct {
Content string ` json:"content" `
} ` json:"message" `
} ` json:"choices" `
}
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
if len ( searchResp . Choices ) == 0 {
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
}
2026-02-20 11:02:00 +00:00
type SearXNGSearchProvider struct {
baseURL string
}
func ( p * SearXNGSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := fmt . Sprintf ( "%s/search?q=%s&format=json&categories=general" ,
strings . TrimSuffix ( p . baseURL , "/" ) ,
url . QueryEscape ( query ) )
req , err := http . NewRequestWithContext ( ctx , "GET" , searchURL , nil )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
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 ( )
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "SearXNG returned status %d" , resp . StatusCode )
}
var result struct {
Results [ ] struct {
Title string ` json:"title" `
URL string ` json:"url" `
Content string ` json:"content" `
Engine string ` json:"engine" `
Score float64 ` json:"score" `
} ` json:"results" `
}
if err := json . NewDecoder ( resp . Body ) . Decode ( & result ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
if len ( result . Results ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
}
// Limit results to requested count
if len ( result . Results ) > count {
result . Results = result . Results [ : count ]
}
// Format results in standard PicoClaw format
var b strings . Builder
b . WriteString ( fmt . Sprintf ( "Results for: %s (via SearXNG)\n" , query ) )
for i , r := range result . Results {
b . WriteString ( fmt . Sprintf ( "%d. %s\n" , i + 1 , r . Title ) )
b . WriteString ( fmt . Sprintf ( " %s\n" , r . URL ) )
if r . Content != "" {
b . WriteString ( fmt . Sprintf ( " %s\n" , r . Content ) )
}
}
return b . String ( ) , nil
}
2026-03-04 06:58:12 +00:00
type GLMSearchProvider struct {
apiKey string
baseURL string
searchEngine string
proxy string
client * http . Client
}
func ( p * GLMSearchProvider ) Search ( ctx context . Context , query string , count int ) ( string , error ) {
searchURL := p . baseURL
if searchURL == "" {
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
}
payload := map [ string ] any {
"search_query" : query ,
"search_engine" : p . searchEngine ,
"search_intent" : false ,
"count" : count ,
"content_size" : "medium" ,
}
bodyBytes , err := json . Marshal ( payload )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal payload: %w" , err )
}
req , err := http . NewRequestWithContext ( ctx , "POST" , searchURL , bytes . NewReader ( bodyBytes ) )
if err != nil {
return "" , fmt . Errorf ( "failed to create request: %w" , err )
}
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "Authorization" , "Bearer " + p . apiKey )
resp , err := p . client . Do ( req )
if err != nil {
return "" , fmt . Errorf ( "request failed: %w" , err )
}
defer resp . Body . Close ( )
body , err := io . ReadAll ( io . LimitReader ( resp . Body , 1 << 20 ) )
if err != nil {
return "" , fmt . Errorf ( "failed to read response: %w" , err )
}
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "GLM Search API error (status %d): %s" , resp . StatusCode , string ( body ) )
}
var searchResp struct {
SearchResult [ ] struct {
Title string ` json:"title" `
Content string ` json:"content" `
Link string ` json:"link" `
} ` json:"search_result" `
}
if err := json . Unmarshal ( body , & searchResp ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse response: %w" , err )
}
results := searchResp . SearchResult
if len ( results ) == 0 {
return fmt . Sprintf ( "No results for: %s" , query ) , nil
}
var lines [ ] string
lines = append ( lines , fmt . Sprintf ( "Results for: %s (via GLM Search)" , query ) )
for i , item := range results {
if i >= count {
break
}
lines = append ( lines , fmt . Sprintf ( "%d. %s\n %s" , i + 1 , item . Title , item . Link ) )
if item . Content != "" {
lines = append ( lines , fmt . Sprintf ( " %s" , item . Content ) )
}
}
return strings . Join ( lines , "\n" ) , nil
}
2026-02-12 16:18:51 +00:00
type WebSearchTool struct {
provider SearchProvider
maxResults int
}
2026-02-13 09:12:55 +00:00
type WebSearchToolOptions struct {
BraveAPIKey string
BraveMaxResults int
BraveEnabled bool
2026-02-22 16:30:14 +00:00
TavilyAPIKey string
TavilyBaseURL string
TavilyMaxResults int
TavilyEnabled bool
2026-02-13 09:12:55 +00:00
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
2026-02-17 13:02:56 +00:00
PerplexityAPIKey string
PerplexityMaxResults int
PerplexityEnabled bool
2026-02-20 11:02:00 +00:00
SearXNGBaseURL string
SearXNGMaxResults int
SearXNGEnabled bool
2026-03-04 06:58:12 +00:00
GLMSearchAPIKey string
GLMSearchBaseURL string
GLMSearchEngine string
GLMSearchMaxResults int
GLMSearchEnabled bool
2026-02-24 09:16:16 +00:00
Proxy string
2026-02-13 09:12:55 +00:00
}
2026-02-12 16:18:51 +00:00
2026-03-01 05:55:46 +00:00
func NewWebSearchTool ( opts WebSearchToolOptions ) ( * WebSearchTool , error ) {
2026-02-12 16:18:51 +00:00
var provider SearchProvider
2026-02-13 09:12:55 +00:00
maxResults := 5
2026-03-04 20:42:03 +00:00
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
2026-02-17 13:02:56 +00:00
if opts . PerplexityEnabled && opts . PerplexityAPIKey != "" {
2026-03-01 05:55:46 +00:00
client , err := createHTTPClient ( opts . Proxy , perplexityTimeout )
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for Perplexity: %w" , err )
}
provider = & PerplexitySearchProvider { apiKey : opts . PerplexityAPIKey , proxy : opts . Proxy , client : client }
2026-02-17 13:02:56 +00:00
if opts . PerplexityMaxResults > 0 {
maxResults = opts . PerplexityMaxResults
}
} else if opts . BraveEnabled && opts . BraveAPIKey != "" {
2026-03-01 05:55:46 +00:00
client , err := createHTTPClient ( opts . Proxy , searchTimeout )
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for Brave: %w" , err )
}
provider = & BraveSearchProvider { apiKey : opts . BraveAPIKey , proxy : opts . Proxy , client : client }
2026-02-13 09:12:55 +00:00
if opts . BraveMaxResults > 0 {
maxResults = opts . BraveMaxResults
}
2026-02-20 11:02:00 +00:00
} else if opts . SearXNGEnabled && opts . SearXNGBaseURL != "" {
provider = & SearXNGSearchProvider { baseURL : opts . SearXNGBaseURL }
if opts . SearXNGMaxResults > 0 {
maxResults = opts . SearXNGMaxResults
}
2026-02-22 16:30:14 +00:00
} else if opts . TavilyEnabled && opts . TavilyAPIKey != "" {
2026-03-01 05:55:46 +00:00
client , err := createHTTPClient ( opts . Proxy , searchTimeout )
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for Tavily: %w" , err )
}
2026-02-22 16:30:14 +00:00
provider = & TavilySearchProvider {
apiKey : opts . TavilyAPIKey ,
baseURL : opts . TavilyBaseURL ,
2026-02-23 08:23:10 +00:00
proxy : opts . Proxy ,
2026-03-01 05:55:46 +00:00
client : client ,
2026-02-22 16:30:14 +00:00
}
if opts . TavilyMaxResults > 0 {
maxResults = opts . TavilyMaxResults
}
2026-02-13 09:12:55 +00:00
} else if opts . DuckDuckGoEnabled {
2026-03-01 05:55:46 +00:00
client , err := createHTTPClient ( opts . Proxy , searchTimeout )
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for DuckDuckGo: %w" , err )
}
provider = & DuckDuckGoSearchProvider { proxy : opts . Proxy , client : client }
2026-02-13 09:12:55 +00:00
if opts . DuckDuckGoMaxResults > 0 {
maxResults = opts . DuckDuckGoMaxResults
}
2026-03-04 06:58:12 +00:00
} else if opts . GLMSearchEnabled && opts . GLMSearchAPIKey != "" {
client , err := createHTTPClient ( opts . Proxy , searchTimeout )
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for GLM Search: %w" , err )
}
searchEngine := opts . GLMSearchEngine
if searchEngine == "" {
searchEngine = "search_std"
}
provider = & GLMSearchProvider {
apiKey : opts . GLMSearchAPIKey ,
baseURL : opts . GLMSearchBaseURL ,
searchEngine : searchEngine ,
proxy : opts . Proxy ,
client : client ,
}
if opts . GLMSearchMaxResults > 0 {
maxResults = opts . GLMSearchMaxResults
}
2026-02-13 09:12:55 +00:00
} else {
2026-03-01 05:55:46 +00:00
return nil , nil
2026-02-12 16:18:51 +00:00
}
return & WebSearchTool {
provider : provider ,
maxResults : maxResults ,
2026-03-01 05:55:46 +00:00
} , nil
2026-02-12 16:18:51 +00:00
}
func ( t * WebSearchTool ) Name ( ) string {
return "web_search"
}
func ( t * WebSearchTool ) Description ( ) string {
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
}
2026-02-18 19:48:23 +00:00
func ( t * WebSearchTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-12 16:18:51 +00:00
"type" : "object" ,
2026-02-18 19:48:23 +00:00
"properties" : map [ string ] any {
"query" : map [ string ] any {
2026-02-12 16:18:51 +00:00
"type" : "string" ,
"description" : "Search query" ,
} ,
2026-02-18 19:48:23 +00:00
"count" : map [ string ] any {
2026-02-12 16:18:51 +00:00
"type" : "integer" ,
"description" : "Number of results (1-10)" ,
"minimum" : 1.0 ,
"maximum" : 10.0 ,
} ,
} ,
"required" : [ ] string { "query" } ,
}
}
2026-02-18 19:48:23 +00:00
func ( t * WebSearchTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-12 16:18:51 +00:00
query , ok := args [ "query" ] . ( string )
if ! ok {
2026-02-13 10:06:43 +00:00
return ErrorResult ( "query is required" )
2026-02-12 16:18:51 +00:00
}
count := t . maxResults
if c , ok := args [ "count" ] . ( float64 ) ; ok {
if int ( c ) > 0 && int ( c ) <= 10 {
count = int ( c )
}
}
2026-02-13 10:11:37 +00:00
result , err := t . provider . Search ( ctx , query , count )
if err != nil {
return ErrorResult ( fmt . Sprintf ( "search failed: %v" , err ) )
}
return & ToolResult {
ForLLM : result ,
ForUser : result ,
}
2026-02-12 16:18:51 +00:00
}
2026-02-04 11:06:13 +00:00
type WebFetchTool struct {
2026-02-28 12:34:33 +00:00
maxChars int
proxy string
2026-03-01 22:44:21 +00:00
client * http . Client
2026-02-28 12:34:33 +00:00
fetchLimitBytes int64
2026-02-04 11:06:13 +00:00
}
2026-03-01 22:44:21 +00:00
func NewWebFetchTool ( maxChars int , fetchLimitBytes int64 ) ( * WebFetchTool , error ) {
// createHTTPClient cannot fail with an empty proxy string.
return NewWebFetchToolWithProxy ( maxChars , "" , fetchLimitBytes )
2026-02-04 11:06:13 +00:00
}
2026-03-01 22:44:21 +00:00
func NewWebFetchToolWithProxy ( maxChars int , proxy string , fetchLimitBytes int64 ) ( * WebFetchTool , error ) {
2026-02-24 09:16:16 +00:00
if maxChars <= 0 {
2026-03-01 05:55:46 +00:00
maxChars = defaultMaxChars
2026-02-24 09:16:16 +00:00
}
2026-03-01 05:55:46 +00:00
client , err := createHTTPClient ( proxy , fetchTimeout )
if err != nil {
return nil , fmt . Errorf ( "failed to create HTTP client for web fetch: %w" , err )
2026-02-24 09:16:16 +00:00
}
2026-03-01 05:55:46 +00:00
client . CheckRedirect = func ( req * http . Request , via [ ] * http . Request ) error {
if len ( via ) >= maxRedirects {
return fmt . Errorf ( "stopped after %d redirects" , maxRedirects )
}
return nil
2026-02-24 09:16:16 +00:00
}
2026-02-28 12:34:33 +00:00
if fetchLimitBytes <= 0 {
fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback
}
2026-02-24 09:16:16 +00:00
return & WebFetchTool {
2026-02-28 12:34:33 +00:00
maxChars : maxChars ,
proxy : proxy ,
2026-03-01 22:44:21 +00:00
client : client ,
2026-02-28 12:34:33 +00:00
fetchLimitBytes : fetchLimitBytes ,
2026-03-01 22:44:21 +00:00
} , nil
2026-02-24 09:16:16 +00:00
}
2026-02-04 11:06:13 +00:00
func ( t * WebFetchTool ) Name ( ) string {
return "web_fetch"
}
func ( t * WebFetchTool ) Description ( ) string {
return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content."
}
2026-02-18 19:48:23 +00:00
func ( t * WebFetchTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-04 11:06:13 +00:00
"type" : "object" ,
2026-02-18 19:48:23 +00:00
"properties" : map [ string ] any {
"url" : map [ string ] any {
2026-02-04 11:06:13 +00:00
"type" : "string" ,
"description" : "URL to fetch" ,
} ,
2026-02-18 19:48:23 +00:00
"maxChars" : map [ string ] any {
2026-02-04 11:06:13 +00:00
"type" : "integer" ,
"description" : "Maximum characters to extract" ,
"minimum" : 100.0 ,
} ,
} ,
"required" : [ ] string { "url" } ,
}
}
2026-02-18 19:48:23 +00:00
func ( t * WebFetchTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-04 11:06:13 +00:00
urlStr , ok := args [ "url" ] . ( string )
if ! ok {
2026-02-12 11:28:56 +00:00
return ErrorResult ( "url is required" )
2026-02-04 11:06:13 +00:00
}
parsedURL , err := url . Parse ( urlStr )
if err != nil {
2026-02-12 11:28:56 +00:00
return ErrorResult ( fmt . Sprintf ( "invalid URL: %v" , err ) )
2026-02-04 11:06:13 +00:00
}
if parsedURL . Scheme != "http" && parsedURL . Scheme != "https" {
2026-02-12 11:28:56 +00:00
return ErrorResult ( "only http/https URLs are allowed" )
2026-02-04 11:06:13 +00:00
}
if parsedURL . Host == "" {
2026-02-12 11:28:56 +00:00
return ErrorResult ( "missing domain in URL" )
2026-02-04 11:06:13 +00:00
}
maxChars := t . maxChars
if mc , ok := args [ "maxChars" ] . ( float64 ) ; ok {
if int ( mc ) > 100 {
maxChars = int ( mc )
}
}
req , err := http . NewRequestWithContext ( ctx , "GET" , urlStr , nil )
if err != nil {
2026-02-12 11:28:56 +00:00
return ErrorResult ( fmt . Sprintf ( "failed to create request: %v" , err ) )
2026-02-04 11:06:13 +00:00
}
req . Header . Set ( "User-Agent" , userAgent )
2026-03-01 05:55:46 +00:00
resp , err := t . client . Do ( req )
2026-02-24 09:16:16 +00:00
if err != nil {
2026-02-12 11:28:56 +00:00
return ErrorResult ( fmt . Sprintf ( "request failed: %v" , err ) )
2026-02-24 09:16:16 +00:00
}
2026-02-28 12:34:33 +00:00
resp . Body = http . MaxBytesReader ( nil , resp . Body , t . fetchLimitBytes )
2026-02-04 11:06:13 +00:00
defer resp . Body . Close ( )
body , err := io . ReadAll ( resp . Body )
if err != nil {
2026-02-27 17:56:02 +00:00
var maxBytesErr * http . MaxBytesError
if errors . As ( err , & maxBytesErr ) {
2026-02-28 12:34:33 +00:00
return ErrorResult ( fmt . Sprintf ( "failed to read response: size exceeded %d bytes limit" , t . fetchLimitBytes ) )
2026-02-27 17:56:02 +00:00
}
2026-02-12 11:28:56 +00:00
return ErrorResult ( fmt . Sprintf ( "failed to read response: %v" , err ) )
2026-02-04 11:06:13 +00:00
}
contentType := resp . Header . Get ( "Content-Type" )
var text , extractor string
if strings . Contains ( contentType , "application/json" ) {
2026-02-18 19:48:23 +00:00
var jsonData any
2026-02-04 11:06:13 +00:00
if err := json . Unmarshal ( body , & jsonData ) ; err == nil {
formatted , _ := json . MarshalIndent ( jsonData , "" , " " )
text = string ( formatted )
extractor = "json"
} else {
text = string ( body )
extractor = "raw"
}
} else if strings . Contains ( contentType , "text/html" ) || len ( body ) > 0 &&
( strings . HasPrefix ( string ( body ) , "<!DOCTYPE" ) || strings . HasPrefix ( strings . ToLower ( string ( body ) ) , "<html" ) ) {
text = t . extractText ( string ( body ) )
extractor = "text"
} else {
text = string ( body )
extractor = "raw"
}
truncated := len ( text ) > maxChars
if truncated {
text = text [ : maxChars ]
}
2026-02-18 19:48:23 +00:00
result := map [ string ] any {
2026-02-04 11:06:13 +00:00
"url" : urlStr ,
"status" : resp . StatusCode ,
"extractor" : extractor ,
"truncated" : truncated ,
"length" : len ( text ) ,
"text" : text ,
}
resultJSON , _ := json . MarshalIndent ( result , "" , " " )
2026-02-12 11:28:56 +00:00
return & ToolResult {
2026-03-01 20:48:11 +00:00
ForLLM : string ( resultJSON ) ,
ForUser : fmt . Sprintf (
2026-02-18 19:48:23 +00:00
"Fetched %d bytes from %s (extractor: %s, truncated: %v)" ,
len ( text ) ,
urlStr ,
extractor ,
truncated ,
) ,
2026-02-12 11:28:56 +00:00
}
2026-02-04 11:06:13 +00:00
}
func ( t * WebFetchTool ) extractText ( htmlContent string ) string {
2026-02-26 09:44:03 +00:00
result := reScript . ReplaceAllLiteralString ( htmlContent , "" )
result = reStyle . ReplaceAllLiteralString ( result , "" )
result = reTags . ReplaceAllLiteralString ( result , "" )
2026-02-04 11:06:13 +00:00
result = strings . TrimSpace ( result )
2026-02-26 09:44:03 +00:00
result = reWhitespace . ReplaceAllString ( result , " " )
result = reBlankLines . ReplaceAllString ( result , "\n\n" )
2026-02-04 11:06:13 +00:00
lines := strings . Split ( result , "\n" )
var cleanLines [ ] string
for _ , line := range lines {
line = strings . TrimSpace ( line )
if line != "" {
cleanLines = append ( cleanLines , line )
}
}
return strings . Join ( cleanLines , "\n" )
}