feat(tools): add optional Parallel web search provider

Signed-off-by: georgeatparallel <george@parallel.ai>
This commit is contained in:
georgeatparallel committed 2026-08-02 05:31:39 -07:00
1 parent 149bb478ab
commit eef6f27775
11 files changed
+395 -24

No files matched your search

+2 -1
View File
@@ -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"},
+10
View File
@@ -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 == "" {
+24 -8
View File
@@ -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 {
+3 -1
View File
@@ -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 ---
+142
View File
@@ -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 &parallelSearchProvider{
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
}
+192
View File
@@ -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 := &parallelSearchProvider{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 := &parallelSearchProvider{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 := &parallelSearchProvider{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 := &parallelSearchProvider{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 := &parallelSearchProvider{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)
}
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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<ProviderKey, string> = {
exa: "bg-blue-600",
tavily: "bg-cyan-500",
brave: "bg-orange-500",
parallel: "bg-violet-500",
duckduckgo: "bg-slate-500",
};
function parseInitialEntries(settings: Record<string, unknown>): 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<string, unknown>;
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
/>
</div>
{/* API key row */}
<div className="flex items-center gap-1.5 mt-2 pl-10">
{/* API key row — Parallel's hosted Search MCP is keyless. */}
{entry.name !== "parallel" && <div className="flex items-center gap-1.5 mt-2 pl-10">
<Label className="text-xs text-muted-foreground whitespace-nowrap">
{t("builtin.searchChain.apiKey")}
</Label>
@@ -173,7 +177,7 @@ function SortableProviderCard({ entry, index, secretsSet, onUpdate }: SortableCa
className="h-7 flex-1 text-base md:text-sm font-mono"
/>
)}
</div>
</div>}
</div>
</div>
);
@@ -190,7 +194,7 @@ function LockedDuckDuckGoCard({ settings }: { settings: Record<string, unknown>
<div className="flex-1 px-3 py-3">
<div className="flex items-center gap-2">
<Lock className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-mono shrink-0">#4</span>
<span className="text-xs text-muted-foreground font-mono shrink-0">#5</span>
<Switch size="sm" checked disabled />
<span className="text-sm font-medium flex-1">
{t("builtin.searchChain.providers.duckduckgo")}
@@ -238,7 +242,7 @@ export function WebSearchChainForm({ initialSettings, secretsSet, onSave, onCanc
const cfg: Record<string, unknown> = { 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;