Rewrite search mechanism to adapt more common
Added ollama search Enhance search result by agent before return to user
This commit is contained in:
parent
8d757fbb6f
commit
7d623fc3af
6 changed files with 247 additions and 50 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model": "glm-4.7",
|
"model": "ollama/qwen2.5:14b-instruct ",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -116,7 +116,11 @@
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"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
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,24 @@ 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))
|
||||||
|
|
||||||
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
// Build web search tool from config - single provider with fallback to DuckDuckGo
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
searchOpts := []tools.WebSearchToolOptions{
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
{
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
Provider: cfg.Tools.Web.Search.Provider,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
APIKey: cfg.Tools.Web.Search.APIKey,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
BaseURL: cfg.Tools.Web.Search.Endpoint,
|
||||||
}); searchTool != nil {
|
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(searchTool)
|
||||||
}
|
}
|
||||||
registry.Register(tools.NewWebFetchTool(50000))
|
registry.Register(tools.NewWebFetchTool(50000))
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,25 @@ type DuckDuckGoConfig struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
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 {
|
type WebToolsConfig struct {
|
||||||
Brave BraveConfig `json:"brave"`
|
Search WebSearchConfig `json:"search"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
|
|
@ -312,13 +328,12 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
Web: WebToolsConfig{
|
Web: WebToolsConfig{
|
||||||
Brave: BraveConfig{
|
Search: WebSearchConfig{
|
||||||
Enabled: false,
|
Provider: "ollama",
|
||||||
APIKey: "",
|
APIKey: "77b893700a1d4c8dad9a7326be9a76d6.7pl0DA9ojPa_6UCMMZ_Sk-Cn",
|
||||||
MaxResults: 5,
|
Endpoint: "https://ollama.com/api/web_search",
|
||||||
},
|
RestType: "POST",
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
QueryParam: "query",
|
||||||
Enabled: true,
|
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -212,17 +212,24 @@ 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.Brave.APIKey = v
|
cfg.Tools.Web.Search.APIKey = v
|
||||||
if v != "" {
|
|
||||||
cfg.Tools.Web.Brave.Enabled = true
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if v, ok := getFloat(search, "max_results"); ok {
|
||||||
cfg.Tools.Web.Brave.MaxResults = int(v)
|
cfg.Tools.Web.Search.MaxResults = int(v)
|
||||||
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,8 +283,23 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
|
||||||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.Tools.Web.Brave.APIKey == "" {
|
if existing.Tools.Web.Search.APIKey == "" {
|
||||||
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
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
|
return existing
|
||||||
|
|
|
||||||
180
pkg/tools/web.go
180
pkg/tools/web.go
|
|
@ -176,44 +176,156 @@ func stripTags(content string) string {
|
||||||
return re.ReplaceAllString(content, "")
|
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 {
|
type WebSearchTool struct {
|
||||||
provider SearchProvider
|
provider SearchProvider
|
||||||
maxResults int
|
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 {
|
type WebSearchToolOptions struct {
|
||||||
BraveAPIKey string
|
Provider string // "brave", "ollama", "duckduckgo"
|
||||||
BraveMaxResults int
|
APIKey string // For Brave API
|
||||||
BraveEnabled bool
|
BaseURL string // For custom providers (e.g., Ollama)
|
||||||
DuckDuckGoMaxResults int
|
MaxResults int // Default: 5
|
||||||
DuckDuckGoEnabled bool
|
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 {
|
func NewWebSearchTool(opts ...WebSearchToolOptions) *WebSearchTool {
|
||||||
var provider SearchProvider
|
// Priority order: Brave > Ollama > DuckDuckGo
|
||||||
maxResults := 5
|
priorityOrder := []string{"brave", "ollama", "duckduckgo"}
|
||||||
|
optMap := make(map[string]WebSearchToolOptions)
|
||||||
|
|
||||||
// Priority: Brave > DuckDuckGo
|
// Build map of enabled providers
|
||||||
if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
for _, opt := range opts {
|
||||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
|
if opt.Provider != "" {
|
||||||
if opts.BraveMaxResults > 0 {
|
optMap[opt.Provider] = opt
|
||||||
maxResults = opts.BraveMaxResults
|
|
||||||
}
|
}
|
||||||
} else if opts.DuckDuckGoEnabled {
|
|
||||||
provider = &DuckDuckGoSearchProvider{}
|
|
||||||
if opts.DuckDuckGoMaxResults > 0 {
|
|
||||||
maxResults = opts.DuckDuckGoMaxResults
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
var selectedOpt *WebSearchToolOptions
|
||||||
|
var provider SearchProvider
|
||||||
|
|
||||||
|
// Try providers in priority order
|
||||||
|
for _, providerName := range priorityOrder {
|
||||||
|
opt, exists := optMap[providerName]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedOpt = &opt
|
||||||
|
var err error
|
||||||
|
provider, err = createProvider(&opt)
|
||||||
|
if err == nil && provider != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxResults := 5
|
||||||
|
if selectedOpt != nil && selectedOpt.MaxResults > 0 {
|
||||||
|
maxResults = selectedOpt.MaxResults
|
||||||
|
}
|
||||||
|
|
||||||
return &WebSearchTool{
|
return &WebSearchTool{
|
||||||
provider: provider,
|
provider: provider,
|
||||||
maxResults: maxResults,
|
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 {
|
func (t *WebSearchTool) Name() string {
|
||||||
return "web_search"
|
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))
|
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{
|
return &ToolResult{
|
||||||
ForLLM: result,
|
ForLLM: result,
|
||||||
ForUser: result,
|
ForUser: result,
|
||||||
|
|
|
||||||
|
|
@ -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
|
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
||||||
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
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 {
|
if tool != nil {
|
||||||
t.Errorf("Expected nil tool when Brave API key is empty")
|
t.Errorf("Expected nil tool when Brave API key is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also nil when nothing is enabled
|
// No providers should return nil
|
||||||
tool = NewWebSearchTool(WebSearchToolOptions{})
|
tool = NewWebSearchTool()
|
||||||
if tool != nil {
|
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
|
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
||||||
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
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()
|
ctx := context.Background()
|
||||||
args := map[string]interface{}{}
|
args := map[string]interface{}{}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue