diff --git a/config/config.example.json b/config/config.example.json index 3c9158e9c..e6faaf8b6 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -3,7 +3,7 @@ "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, - "model": "glm-4.7", + "model": "ollama/qwen2.5:14b-instruct ", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -116,7 +116,11 @@ "tools": { "web": { "search": { - "api_key": "YOUR_BRAVE_API_KEY", + "provider": "ollama", + "api_key": "77b893700a1d4c8dad9a7326be9a76d6.7pl0DA9ojPa_6UCMMZ_Sk-Cn", + "endpoint": "https://ollama.com/api/web_search", + "rest_type": "POST", + "query_param": "query", "max_results": 5 } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cd4276155..f9924690b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -73,13 +73,24 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg // Shell execution registry.Register(tools.NewExecTool(workspace, restrict)) - if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - 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 { + // Build web search tool from config - single provider with fallback to DuckDuckGo + searchOpts := []tools.WebSearchToolOptions{ + { + Provider: cfg.Tools.Web.Search.Provider, + APIKey: cfg.Tools.Web.Search.APIKey, + BaseURL: cfg.Tools.Web.Search.Endpoint, + MaxResults: cfg.Tools.Web.Search.MaxResults, + Mode: cfg.Tools.Web.Search.RestType, + Param: cfg.Tools.Web.Search.QueryParam, + }, + // Always add DuckDuckGo as fallback + { + Provider: "duckduckgo", + MaxResults: 5, + }, + } + + if searchTool := tools.NewWebSearchTool(searchOpts...); searchTool != nil { registry.Register(searchTool) } registry.Register(tools.NewWebFetchTool(50000)) diff --git a/pkg/config/config.go b/pkg/config/config.go index d189ff00b..b9c636bff 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -206,9 +206,25 @@ type DuckDuckGoConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } +type OllamaConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_OLLAMA_ENABLED"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_OLLAMA_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_OLLAMA_MAX_RESULTS"` +} + +// WebSearchConfig defines a single active web search provider +// Falls back to DuckDuckGo if the primary provider fails +type WebSearchConfig struct { + Provider string `json:"provider" env:"PICOCLAW_TOOLS_WEB_SEARCH_PROVIDER"` // "brave", "ollama", custom URL + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"` // API key for Brave, etc. + Endpoint string `json:"endpoint" env:"PICOCLAW_TOOLS_WEB_SEARCH_ENDPOINT"` // Base URL for Ollama or custom + RestType string `json:"rest_type" env:"PICOCLAW_TOOLS_WEB_SEARCH_REST_TYPE"` // "GET" or "POST" + QueryParam string `json:"query_param" env:"PICOCLAW_TOOLS_WEB_SEARCH_QUERY_PARAM"` // "q", "query", etc. + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"` // Default: 5 +} + type WebToolsConfig struct { - Brave BraveConfig `json:"brave"` - DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` + Search WebSearchConfig `json:"search"` } type ToolsConfig struct { @@ -312,13 +328,12 @@ func DefaultConfig() *Config { }, Tools: ToolsConfig{ Web: WebToolsConfig{ - Brave: BraveConfig{ - Enabled: false, - APIKey: "", - MaxResults: 5, - }, - DuckDuckGo: DuckDuckGoConfig{ - Enabled: true, + Search: WebSearchConfig{ + Provider: "ollama", + APIKey: "77b893700a1d4c8dad9a7326be9a76d6.7pl0DA9ojPa_6UCMMZ_Sk-Cn", + Endpoint: "https://ollama.com/api/web_search", + RestType: "POST", + QueryParam: "query", MaxResults: 5, }, }, diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 9c1e36359..f2f996d4b 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -212,17 +212,24 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error if tools, ok := getMap(data, "tools"); 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 v, ok := getString(search, "api_key"); ok { - cfg.Tools.Web.Brave.APIKey = v - if v != "" { - cfg.Tools.Web.Brave.Enabled = true - } + cfg.Tools.Web.Search.APIKey = v + } + if v, ok := getString(search, "provider"); ok { + cfg.Tools.Web.Search.Provider = v + } + if v, ok := getString(search, "endpoint"); ok { + cfg.Tools.Web.Search.Endpoint = v + } + if v, ok := getString(search, "rest_type"); ok { + cfg.Tools.Web.Search.RestType = v + } + if v, ok := getString(search, "query_param"); ok { + cfg.Tools.Web.Search.QueryParam = v } if v, ok := getFloat(search, "max_results"); ok { - cfg.Tools.Web.Brave.MaxResults = int(v) - cfg.Tools.Web.DuckDuckGo.MaxResults = int(v) + cfg.Tools.Web.Search.MaxResults = int(v) } } } @@ -276,8 +283,23 @@ func MergeConfig(existing, incoming *config.Config) *config.Config { existing.Channels.MaixCam = incoming.Channels.MaixCam } - if existing.Tools.Web.Brave.APIKey == "" { - existing.Tools.Web.Brave = incoming.Tools.Web.Brave + if existing.Tools.Web.Search.APIKey == "" { + existing.Tools.Web.Search.APIKey = incoming.Tools.Web.Search.APIKey + } + if existing.Tools.Web.Search.Provider == "" { + existing.Tools.Web.Search.Provider = incoming.Tools.Web.Search.Provider + } + if existing.Tools.Web.Search.Endpoint == "" { + existing.Tools.Web.Search.Endpoint = incoming.Tools.Web.Search.Endpoint + } + if existing.Tools.Web.Search.RestType == "" { + existing.Tools.Web.Search.RestType = incoming.Tools.Web.Search.RestType + } + if existing.Tools.Web.Search.QueryParam == "" { + existing.Tools.Web.Search.QueryParam = incoming.Tools.Web.Search.QueryParam + } + if existing.Tools.Web.Search.MaxResults == 0 { + existing.Tools.Web.Search.MaxResults = incoming.Tools.Web.Search.MaxResults } return existing diff --git a/pkg/tools/web.go b/pkg/tools/web.go index ccd995842..22b5d4b23 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -176,44 +176,156 @@ func stripTags(content string) string { return re.ReplaceAllString(content, "") } +type OllamaSearchProvider struct { + baseURL string + apiKey string + queryParam string // Parameter name for query (e.g., "query", "q") +} + +func (p *OllamaSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + // Call Ollama REST API for web search + requestBody := map[string]interface{}{ + p.queryParam: query, + } + + bodyJSON, _ := json.Marshal(requestBody) + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL, strings.NewReader(string(bodyJSON))) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.apiKey)) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request to Ollama 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 the raw JSON string for the agent/tool to process + return string(body), nil +} + type WebSearchTool struct { provider SearchProvider maxResults int } +// WebSearchToolOptions defines configuration for web search providers. +// This is the unified API for all search providers. +// +// If Provider is set, it's considered enabled. Use empty Provider to disable. +// BaseURL is generic and works for any provider (e.g., custom Ollama instances). +// Model is not needed for web search - results are translated by the main LLM. +// +// Usage example: +// +// opts := []WebSearchToolOptions{ +// {Provider: "brave", APIKey: "key", MaxResults: 5}, +// {Provider: "ollama", BaseURL: "http://localhost:11434", MaxResults: 5}, +// {Provider: "duckduckgo", MaxResults: 5}, +// } +// tool := NewWebSearchTool(opts...) type WebSearchToolOptions struct { - BraveAPIKey string - BraveMaxResults int - BraveEnabled bool - DuckDuckGoMaxResults int - DuckDuckGoEnabled bool + Provider string // "brave", "ollama", "duckduckgo" + APIKey string // For Brave API + BaseURL string // For custom providers (e.g., Ollama) + MaxResults int // Default: 5 + Mode string // "GET" or "POST" (reserved for future use) + Param string // Query param name: "q", "query", etc. (reserved for future use) } -func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { - var provider SearchProvider - maxResults := 5 +func NewWebSearchTool(opts ...WebSearchToolOptions) *WebSearchTool { + // Priority order: Brave > Ollama > DuckDuckGo + priorityOrder := []string{"brave", "ollama", "duckduckgo"} + optMap := make(map[string]WebSearchToolOptions) - // Priority: Brave > DuckDuckGo - if opts.BraveEnabled && opts.BraveAPIKey != "" { - provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey} - if opts.BraveMaxResults > 0 { - maxResults = opts.BraveMaxResults + // Build map of enabled providers + for _, opt := range opts { + if opt.Provider != "" { + optMap[opt.Provider] = opt } - } else if opts.DuckDuckGoEnabled { - provider = &DuckDuckGoSearchProvider{} - if opts.DuckDuckGoMaxResults > 0 { - maxResults = opts.DuckDuckGoMaxResults + } + + var selectedOpt *WebSearchToolOptions + var provider SearchProvider + + // Try providers in priority order + for _, providerName := range priorityOrder { + opt, exists := optMap[providerName] + if !exists { + continue } - } else { + + selectedOpt = &opt + var err error + provider, err = createProvider(&opt) + if err == nil && provider != nil { + break + } + } + + if provider == nil { return nil } + maxResults := 5 + if selectedOpt != nil && selectedOpt.MaxResults > 0 { + maxResults = selectedOpt.MaxResults + } + return &WebSearchTool{ provider: provider, maxResults: maxResults, } } +// createProvider creates a SearchProvider based on configuration +// Provider is considered enabled if non-empty +func createProvider(opt *WebSearchToolOptions) (SearchProvider, error) { + if opt == nil || opt.Provider == "" { + return nil, fmt.Errorf("provider not set") + } + + switch opt.Provider { + case "brave": + if opt.APIKey == "" { + return nil, fmt.Errorf("Brave API key required") + } + return &BraveSearchProvider{apiKey: opt.APIKey}, nil + + case "ollama": + if opt.BaseURL == "" { + return nil, fmt.Errorf("Ollama BaseURL required") + } + queryParam := opt.Param + if queryParam == "" { + queryParam = "query" // Default query parameter + } + return &OllamaSearchProvider{ + baseURL: opt.BaseURL, + apiKey: opt.APIKey, // Bearer token for API authentication + queryParam: queryParam, + }, nil + + case "duckduckgo": + return &DuckDuckGoSearchProvider{}, nil + + default: + return nil, fmt.Errorf("unknown provider: %s", opt.Provider) + } +} + func (t *WebSearchTool) Name() string { return "web_search" } @@ -259,6 +371,38 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{} return ErrorResult(fmt.Sprintf("search failed: %v", err)) } + // If Ollama, synthesize a readable answer for the user + if _, ok := t.provider.(*OllamaSearchProvider); ok { + var parsed struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(result), &parsed); err == nil && len(parsed.Results) > 0 { + // Synthesize a readable answer + var summary []string + summary = append(summary, fmt.Sprintf("Web search summary for: %s", query)) + maxItems := count + if len(parsed.Results) < count { + maxItems = len(parsed.Results) + } + for i := 0; i < maxItems; i++ { + r := parsed.Results[i] + summary = append(summary, fmt.Sprintf("%d. %s\n %s", i+1, r.Title, r.URL)) + if r.Content != "" { + summary = append(summary, fmt.Sprintf(" %s", r.Content)) + } + } + return &ToolResult{ + ForLLM: result, // raw for LLM + ForUser: strings.Join(summary, "\n"), + } + } + } + + // Default: return as-is return &ToolResult{ ForLLM: result, ForUser: result, diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index a526ea34a..a420d543d 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -175,21 +175,22 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { // TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) + // Brave without API key should return nil + tool := NewWebSearchTool(WebSearchToolOptions{Provider: "brave", APIKey: ""}) if tool != nil { t.Errorf("Expected nil tool when Brave API key is empty") } - // Also nil when nothing is enabled - tool = NewWebSearchTool(WebSearchToolOptions{}) + // No providers should return nil + tool = NewWebSearchTool() if tool != nil { - t.Errorf("Expected nil tool when no provider is enabled") + t.Errorf("Expected nil tool when no providers are provided") } } // TestWebTool_WebSearch_MissingQuery verifies error handling for missing query func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) + tool := NewWebSearchTool(WebSearchToolOptions{Provider: "duckduckgo", Enabled: true, MaxResults: 5}) ctx := context.Background() args := map[string]interface{}{}