diff --git a/internal/tools/web_search.go b/internal/tools/web_search.go index 7cd6eb99..ea6698d1 100644 --- a/internal/tools/web_search.go +++ b/internal/tools/web_search.go @@ -30,6 +30,7 @@ const ( searchProviderExa = "exa" searchProviderTavily = "tavily" searchProviderBrave = "brave" + searchProviderParallel = "parallel" searchProviderDuckDuckGo = "duckduckgo" ) @@ -165,7 +166,7 @@ func (t *WebSearchTool) Parameters() map[string]any { }, "provider": map[string]any{ "type": "string", - "description": "Optional: force a specific provider (e.g., 'tavily', 'exa', 'brave', 'duckduckgo'). When omitted, the tenant's configured provider chain is used (first-success-wins). Use this to force cross-engine corroboration — call once with each provider and compare results.", + "description": "Optional: force a specific provider (e.g., 'tavily', 'exa', 'brave', 'parallel', 'duckduckgo'). When omitted, the tenant's configured provider chain is used (first-success-wins). Use this to force cross-engine corroboration — call once with each provider and compare results.", }, }, "required": []string{"query"}, diff --git a/internal/tools/web_search_chain.go b/internal/tools/web_search_chain.go index e6102174..7642f27b 100644 --- a/internal/tools/web_search_chain.go +++ b/internal/tools/web_search_chain.go @@ -191,6 +191,16 @@ func BuildChainFromStorage(ctx context.Context, secrets store.ConfigSecretsStore if isDisabled(name) { continue } + if name == searchProviderParallel { + // Parallel is keyless and intentionally absent from the default + // order. Reaching this branch means the tenant explicitly named it. + maxResults := defaultSearchCount + if po, ok := override.Providers[name]; ok && po.MaxResults > 0 { + maxResults = po.MaxResults + } + chain = append(chain, buildProviderByName(name, "", maxResults)) + continue + } key, err := secrets.Get(ctx, "tools.web."+name+".api_key") if err != nil || key == "" { diff --git a/internal/tools/web_search_chain_test.go b/internal/tools/web_search_chain_test.go index 6b01f1c9..cfc13df5 100644 --- a/internal/tools/web_search_chain_test.go +++ b/internal/tools/web_search_chain_test.go @@ -146,10 +146,10 @@ func TestBuildChainFromStorage(t *testing.T) { wantLen: 3, }, { - name: "scenario 3: override brave only, no brave key → [duckduckgo] (DDG always present)", - tenantID: uuid.New(), - override: `{"provider_order":["brave"]}`, - secrets: map[string]string{}, + name: "scenario 3: override brave only, no brave key → [duckduckgo] (DDG always present)", + tenantID: uuid.New(), + override: `{"provider_order":["brave"]}`, + secrets: map[string]string{}, wantNames: []string{"duckduckgo"}, wantLen: 1, }, @@ -164,10 +164,10 @@ func TestBuildChainFromStorage(t *testing.T) { wantLen: 2, }, { - name: "scenario 5: DDG explicitly disabled → still present (force-enabled)", - tenantID: uuid.New(), - override: `{"duckduckgo":{"enabled":false}}`, - secrets: map[string]string{}, + name: "scenario 5: DDG explicitly disabled → still present (force-enabled)", + tenantID: uuid.New(), + override: `{"duckduckgo":{"enabled":false}}`, + secrets: map[string]string{}, wantNames: []string{"duckduckgo"}, wantLen: 1, }, @@ -181,6 +181,22 @@ func TestBuildChainFromStorage(t *testing.T) { wantNames: []string{"duckduckgo"}, wantLen: 1, }, + { + name: "explicit Parallel needs no secret and stays before DDG", + tenantID: uuid.New(), + override: `{"provider_order":["parallel"],"parallel":{"enabled":true,"max_results":3}}`, + secrets: map[string]string{}, + wantNames: []string{"parallel", "duckduckgo"}, + wantLen: 2, + }, + { + name: "explicitly disabled Parallel is skipped", + tenantID: uuid.New(), + override: `{"provider_order":["parallel"],"parallel":{"enabled":false}}`, + secrets: map[string]string{}, + wantNames: []string{"duckduckgo"}, + wantLen: 1, + }, } for _, tt := range tests { diff --git a/internal/tools/web_search_config.go b/internal/tools/web_search_config.go index bb45002d..bacbf24c 100644 --- a/internal/tools/web_search_config.go +++ b/internal/tools/web_search_config.go @@ -19,6 +19,8 @@ func buildProviderByName(name, apiKey string, maxResults int) SearchProvider { return newTavilySearchProvider(apiKey, maxResults) case searchProviderBrave: return newBraveSearchProvider(apiKey, maxResults) + case searchProviderParallel: + return newParallelSearchProvider(maxResults) case searchProviderDuckDuckGo: return newDuckDuckGoSearchProvider(maxResults) default: @@ -57,7 +59,7 @@ func NormalizeWebSearchProviderOrder(order []string) []string { } func isKnownSearchProvider(id string) bool { - return slices.Contains(defaultSearchProviderOrder, id) + return id == searchProviderParallel || slices.Contains(defaultSearchProviderOrder, id) } // --- Shared provider helpers --- diff --git a/internal/tools/web_search_parallel.go b/internal/tools/web_search_parallel.go new file mode 100644 index 00000000..9b5c5923 --- /dev/null +++ b/internal/tools/web_search_parallel.go @@ -0,0 +1,142 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const parallelSearchEndpoint = "https://search.parallel.ai/mcp" + +type parallelSearchProvider struct { + maxResults int + client *http.Client + endpoint string +} + +func newParallelSearchProvider(maxResults int) *parallelSearchProvider { + return ¶llelSearchProvider{ + maxResults: normalizeProviderMaxResults(maxResults), + client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second}, + endpoint: parallelSearchEndpoint, + } +} + +func (p *parallelSearchProvider) Name() string { return searchProviderParallel } + +type parallelRPCResponse struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +type parallelSearchPayload struct { + Results []struct { + URL string `json:"url"` + Title string `json:"title"` + Excerpts []string `json:"excerpts"` + } `json:"results"` +} + +func (p *parallelSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) { + limit := clampProviderResultCount(params.Count, p.maxResults) + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": "web_search", + "arguments": map[string]any{ + "objective": params.Query, + "search_queries": []string{params.Query}, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(requestBody)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", webSearchUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("parallel Search MCP returned %d", resp.StatusCode) + } + + var rpc parallelRPCResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rpc); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + if rpc.Error != nil { + return nil, fmt.Errorf("parallel Search MCP error %d: %s", rpc.Error.Code, rpc.Error.Message) + } + if rpc.Result.IsError { + return nil, fmt.Errorf("parallel web_search returned an error") + } + + results := make([]searchResult, 0, limit) + foundText := false + for _, content := range rpc.Result.Content { + if content.Type != "text" || strings.TrimSpace(content.Text) == "" { + continue + } + foundText = true + + var payload parallelSearchPayload + if err := json.Unmarshal([]byte(content.Text), &payload); err != nil { + return nil, fmt.Errorf("parse search results: %w", err) + } + for _, raw := range payload.Results { + if len(results) >= limit { + break + } + url := strings.TrimSpace(raw.URL) + if url == "" { + continue + } + description := "" + for _, excerpt := range raw.Excerpts { + if excerpt = strings.TrimSpace(excerpt); excerpt != "" { + description = truncateStr(excerpt, 240) + break + } + } + results = append(results, searchResult{ + Title: coalesceSearchText(raw.Title, url, "Untitled"), + URL: url, + Description: description, + }) + } + if len(results) >= limit { + break + } + } + if !foundText { + return nil, fmt.Errorf("parallel Search MCP response contained no text results") + } + return results, nil +} diff --git a/internal/tools/web_search_parallel_test.go b/internal/tools/web_search_parallel_test.go new file mode 100644 index 00000000..dbec0e03 --- /dev/null +++ b/internal/tools/web_search_parallel_test.go @@ -0,0 +1,192 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +func TestParallelSearchRequestAndResponse(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + var body struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments struct { + Objective string `json:"objective"` + SearchQueries []string `json:"search_queries"` + } `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body.JSONRPC != "2.0" || body.Method != "tools/call" || body.Params.Name != "web_search" { + t.Errorf("unexpected RPC envelope: %+v", body) + } + if body.Params.Arguments.Objective != "current Go release" { + t.Errorf("objective = %q", body.Params.Arguments.Objective) + } + if want := []string{"current Go release"}; !reflect.DeepEqual(body.Params.Arguments.SearchQueries, want) { + t.Errorf("search_queries = %v, want %v", body.Params.Arguments.SearchQueries, want) + } + + writeParallelSearchResponse(t, w, `{"results":[ + {"url":"https://go.dev/doc/devel/release","title":"Go releases","excerpts":[" Current release notes. ","More context."]}, + {"url":"https://go.dev/","title":"The Go Programming Language","excerpts":[]} + ]}`) + })) + defer server.Close() + + provider := ¶llelSearchProvider{maxResults: 5, client: server.Client(), endpoint: server.URL} + results, err := provider.Search(context.Background(), searchParams{Query: "current Go release", Count: 2}) + if err != nil { + t.Fatal(err) + } + want := []searchResult{ + {Title: "Go releases", URL: "https://go.dev/doc/devel/release", Description: "Current release notes."}, + {Title: "The Go Programming Language", URL: "https://go.dev/"}, + } + if !reflect.DeepEqual(results, want) { + t.Fatalf("results = %#v, want %#v", results, want) + } +} + +func TestParallelSearchAppliesLimitAndSkipsMissingURL(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeParallelSearchResponse(t, w, `{"results":[ + {"url":"","title":"missing URL","excerpts":["skip"]}, + {"url":"https://example.com/1","title":"one","excerpts":["one"]}, + {"url":"https://example.com/2","title":"two","excerpts":["two"]} + ]}`) + })) + defer server.Close() + + provider := ¶llelSearchProvider{maxResults: 1, client: server.Client(), endpoint: server.URL} + results, err := provider.Search(context.Background(), searchParams{Query: "q", Count: 5}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Title != "one" { + t.Fatalf("results = %#v, want first valid result", results) + } +} + +func TestParallelSearchResponseErrors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + want string + }{ + {name: "malformed envelope", body: `{`, want: "parse response"}, + {name: "json-rpc error", body: `{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad arguments"}}`, want: "error -32602"}, + {name: "tool error", body: `{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[]}}`, want: "returned an error"}, + {name: "missing text", body: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"image"}]}}`, want: "no text results"}, + {name: "malformed nested results", body: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{"}]}}`, want: "parse search results"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, tc.body) + })) + defer server.Close() + + provider := ¶llelSearchProvider{maxResults: 1, client: server.Client(), endpoint: server.URL} + _, err := provider.Search(context.Background(), searchParams{Query: "q", Count: 1}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", err, tc.want) + } + }) + } +} + +func TestParallelSearchHTTPError(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + provider := ¶llelSearchProvider{maxResults: 1, client: server.Client(), endpoint: server.URL} + _, err := provider.Search(context.Background(), searchParams{Query: "q", Count: 1}) + if err == nil || !strings.Contains(err.Error(), "returned 503") { + t.Fatalf("error = %v, want status 503", err) + } +} + +func TestParallelSearchPropagatesCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + client := &http.Client{Transport: parallelRoundTripFunc(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + })} + provider := ¶llelSearchProvider{maxResults: 1, client: client, endpoint: "https://example.com/mcp"} + _, err := provider.Search(ctx, searchParams{Query: "q", Count: 1}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want deadline exceeded", err) + } +} + +func TestParallelProviderIsExplicitOnly(t *testing.T) { + if got := NormalizeWebSearchProviderOrder(nil); containsString(got, searchProviderParallel) { + t.Fatalf("default provider order unexpectedly contains parallel: %v", got) + } + got := NormalizeWebSearchProviderOrder([]string{searchProviderParallel}) + if len(got) == 0 || got[0] != searchProviderParallel { + t.Fatalf("explicit provider order = %v, want parallel first", got) + } + if provider := buildProviderByName(searchProviderParallel, "", 3); provider == nil || provider.Name() != searchProviderParallel { + t.Fatalf("parallel provider construction failed: %T", provider) + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +type parallelRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f parallelRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func writeParallelSearchResponse(t *testing.T, w http.ResponseWriter, payload string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": map[string]any{ + "content": []map[string]string{{"type": "text", "text": payload}}, + }, + }); err != nil { + t.Fatalf("encode response: %v", err) + } +} diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json index bde8a13d..29aa8251 100644 --- a/ui/web/src/i18n/locales/en/tools.json +++ b/ui/web/src/i18n/locales/en/tools.json @@ -156,7 +156,7 @@ }, "searchChain": { "title": "Web Search Provider Chain", - "description": "Drag to reorder search providers. First enabled provider with a valid API key is tried first.", + "description": "Drag to reorder search providers. The first enabled and available provider is tried first.", "maxResults": "Max results", "locked": "Always-on fallback", "cancel": "Cancel", @@ -166,6 +166,7 @@ "exa": "Exa", "tavily": "Tavily", "brave": "Brave Search", + "parallel": "Parallel", "duckduckgo": "DuckDuckGo" }, "apiKey": "API Key", diff --git a/ui/web/src/i18n/locales/ru/tools.json b/ui/web/src/i18n/locales/ru/tools.json index 71ac15f0..92a84b34 100644 --- a/ui/web/src/i18n/locales/ru/tools.json +++ b/ui/web/src/i18n/locales/ru/tools.json @@ -155,7 +155,7 @@ }, "searchChain": { "title": "Цепочка провайдеров веб-поиска", - "description": "Перетащите, чтобы изменить порядок провайдеров поиска. Первым пробуется первый включённый провайдер с корректным ключом API.", + "description": "Перетащите, чтобы изменить порядок провайдеров поиска. Первым пробуется первый включённый и доступный провайдер.", "maxResults": "Макс. результатов", "locked": "Постоянный резервный вариант", "cancel": "Отмена", @@ -165,6 +165,7 @@ "exa": "Exa", "tavily": "Tavily", "brave": "Brave Search", + "parallel": "Parallel", "duckduckgo": "DuckDuckGo" }, "apiKey": "Ключ API", diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json index 0c932ee7..1ee07c1a 100644 --- a/ui/web/src/i18n/locales/vi/tools.json +++ b/ui/web/src/i18n/locales/vi/tools.json @@ -156,7 +156,7 @@ }, "searchChain": { "title": "Chuỗi nhà cung cấp tìm kiếm web", - "description": "Kéo để sắp xếp lại nhà cung cấp tìm kiếm. Nhà cung cấp đầu tiên được bật với API key hợp lệ sẽ được thử trước.", + "description": "Kéo để sắp xếp lại nhà cung cấp tìm kiếm. Nhà cung cấp đầu tiên được bật và khả dụng sẽ được thử trước.", "maxResults": "Số kết quả tối đa", "locked": "Dự phòng luôn bật", "cancel": "Hủy", @@ -166,6 +166,7 @@ "exa": "Exa", "tavily": "Tavily", "brave": "Brave Search", + "parallel": "Parallel", "duckduckgo": "DuckDuckGo" }, "apiKey": "API Key", diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json index 44098b8b..621ffdef 100644 --- a/ui/web/src/i18n/locales/zh/tools.json +++ b/ui/web/src/i18n/locales/zh/tools.json @@ -70,7 +70,7 @@ }, "searchChain": { "title": "网页搜索 Provider 链", - "description": "拖动以重新排序搜索 Provider。第一个已启用且具有有效 API Key 的 Provider 将被优先尝试。", + "description": "拖动以重新排序搜索 Provider。第一个已启用且可用的 Provider 将被优先尝试。", "maxResults": "最大结果数", "locked": "始终启用的回退", "cancel": "取消", @@ -80,6 +80,7 @@ "exa": "Exa", "tavily": "Tavily", "brave": "Brave Search", + "parallel": "Parallel", "duckduckgo": "DuckDuckGo" }, "apiKey": "API Key", diff --git a/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx b/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx index 364d2515..b8aa129e 100644 --- a/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx +++ b/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx @@ -25,7 +25,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; -type ProviderKey = "exa" | "tavily" | "brave" | "duckduckgo"; +type ProviderKey = "exa" | "tavily" | "brave" | "parallel" | "duckduckgo"; interface ProviderEntry { id: string; @@ -44,30 +44,34 @@ interface Props { onCancel: () => void; } -const SORTABLE_PROVIDERS: ProviderKey[] = ["exa", "tavily", "brave"]; +const SORTABLE_PROVIDERS: ProviderKey[] = ["exa", "tavily", "brave", "parallel"]; const LOCKED_PROVIDER: ProviderKey = "duckduckgo"; -const DEFAULT_ORDER: ProviderKey[] = ["exa", "tavily", "brave"]; +const DEFAULT_ORDER: ProviderKey[] = ["exa", "tavily", "brave", "parallel"]; const RAIL_COLOR: Record = { exa: "bg-blue-600", tavily: "bg-cyan-500", brave: "bg-orange-500", + parallel: "bg-violet-500", duckduckgo: "bg-slate-500", }; function parseInitialEntries(settings: Record): ProviderEntry[] { - const rawOrder = Array.isArray(settings.provider_order) + const savedOrder = Array.isArray(settings.provider_order) ? (settings.provider_order as string[]).filter((p): p is ProviderKey => SORTABLE_PROVIDERS.includes(p as ProviderKey), ) : DEFAULT_ORDER; + const rawOrder: ProviderKey[] = savedOrder.includes("parallel") + ? savedOrder + : [...savedOrder, "parallel"]; return rawOrder.map((name) => { const cfg = (settings[name] ?? {}) as Record; return { id: uniqueId(), name, - enabled: Boolean(cfg.enabled ?? true), + enabled: Boolean(cfg.enabled ?? name !== "parallel"), max_results: cfg.max_results != null ? Number(cfg.max_results) : undefined, }; }); @@ -140,8 +144,8 @@ function SortableProviderCard({ entry, index, secretsSet, onUpdate }: SortableCa /> - {/* API key row */} -
+ {/* API key row — Parallel's hosted Search MCP is keyless. */} + {entry.name !== "parallel" &&
@@ -173,7 +177,7 @@ function SortableProviderCard({ entry, index, secretsSet, onUpdate }: SortableCa className="h-7 flex-1 text-base md:text-sm font-mono" /> )} -
+
} ); @@ -190,7 +194,7 @@ function LockedDuckDuckGoCard({ settings }: { settings: Record
- #4 + #5 {t("builtin.searchChain.providers.duckduckgo")} @@ -238,7 +242,7 @@ export function WebSearchChainForm({ initialSettings, secretsSet, onSave, onCanc const cfg: Record = { enabled: entry.enabled }; if (entry.max_results != null) cfg.max_results = entry.max_results; // Include api_key only when user typed a new value — backend extracts and strips it - if (entry.apiKey && entry.apiKey.trim() !== "") { + if (entry.name !== "parallel" && entry.apiKey && entry.apiKey.trim() !== "") { cfg.api_key = entry.apiKey.trim(); } settings[entry.name] = cfg;