diff --git a/.github/labeler.yml b/.github/labeler.yml index 8a359fea3b96..a72e1a427164 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -574,6 +574,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/openshell/**" +"extensions: parallel": + - changed-files: + - any-glob-to-any-file: + - "extensions/parallel/**" "extensions: perplexity": - changed-files: - any-glob-to-any-file: diff --git a/docs/docs.json b/docs/docs.json index 221fc23f2f7b..ce35a56d47cf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1331,6 +1331,7 @@ "tools/kimi-search", "tools/minimax-search", "tools/ollama-search", + "tools/parallel-search", "tools/perplexity-search", "tools/searxng-search", "tools/tavily" diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 14076aba19fa..b63bc4a13acf 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -50,6 +50,7 @@ Scope intent: - `plugins.entries.firecrawl.config.webSearch.apiKey` - `plugins.entries.minimax.config.webSearch.apiKey` - `plugins.entries.tavily.config.webSearch.apiKey` +- `plugins.entries.parallel.config.webSearch.apiKey` - `plugins.entries.voice-call.config.realtime.providers.*.apiKey` - `plugins.entries.voice-call.config.streaming.providers.*.apiKey` - `plugins.entries.voice-call.config.tts.providers.*.apiKey` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index ee767548df19..c983df687009 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -589,6 +589,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.parallel.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.parallel.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "plugins.entries.perplexity.config.webSearch.apiKey", "configFile": "openclaw.json", diff --git a/docs/tools/parallel-search.md b/docs/tools/parallel-search.md new file mode 100644 index 000000000000..c5aedab543ee --- /dev/null +++ b/docs/tools/parallel-search.md @@ -0,0 +1,127 @@ +--- +summary: "Parallel Search -- LLM-optimized dense excerpts from web sources" +read_when: + - You want to use Parallel for web_search + - You need a PARALLEL_API_KEY + - You want dense excerpts ranked for LLM context efficiency +title: "Parallel search" +--- + +OpenClaw supports [Parallel](https://parallel.ai/) as a `web_search` provider. +Parallel returns ranked, LLM-optimized dense excerpts from a web index +purpose-built for AI agents. + +## Get an API key + + + + Sign up at [platform.parallel.ai](https://platform.parallel.ai) and + generate an API key from your dashboard. + + + Set `PARALLEL_API_KEY` in the Gateway environment, or configure via: + + ```bash + openclaw configure --section web + ``` + + + + +## Config + +```json5 +{ + plugins: { + entries: { + parallel: { + config: { + webSearch: { + apiKey: "par-...", // optional if PARALLEL_API_KEY is set + baseUrl: "https://api.parallel.ai", // optional; OpenClaw appends /v1/search + }, + }, + }, + }, + }, + tools: { + web: { + search: { + provider: "parallel", + }, + }, + }, +} +``` + +**Environment alternative:** set `PARALLEL_API_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +## Base URL override + +Set `plugins.entries.parallel.config.webSearch.baseUrl` when Parallel requests +should go through a compatible proxy or alternate Parallel endpoint (for +example, the Cloudflare AI Gateway). OpenClaw normalizes bare hosts by +prepending `https://` and appends `/v1/search` unless the path already ends +there. The resolved endpoint is included in the search cache key, so results +from different Parallel endpoints are not shared. + +## Tool parameters + +OpenClaw exposes Parallel's native search shape so the model can fill in both +the natural-language goal and a few short keyword queries — the pairing +Parallel [recommends](https://docs.parallel.ai/search/best-practices) for +best results. + + +Natural-language description of the underlying question or goal (max 5000 +chars). Should be self-contained. + + + +Concise keyword search queries, 3-6 words each (1-5 entries, max 200 chars +each). Provide 2-3 diverse queries for best results. + + + +Results to return (1-40). + + + +Optional Parallel session id (max 1000 chars). Pass the `sessionId` from a +previous Parallel result on follow-up searches that are part of the same task +so Parallel can group related calls and improve subsequent results. + + + +Optional identifier of the model making the call (e.g. `claude-opus-4-7`, +`gpt-5.5`). Lets Parallel tailor default settings for your model's +capabilities. Pass the exact active model slug; do not shorten to a family +alias. + + +## Notes + +- Parallel ranks and compresses results based on LLM reasoning utility, not + human click-through; expect dense excerpts in each result rather than + full-page content +- Result excerpts come back as the `excerpts` array and are also joined into + the `description` field for compatibility with the generic `web_search` + contract +- Parallel returns a `session_id` on every response; OpenClaw surfaces it as + `sessionId` in the tool payload so callers can group follow-up searches +- `searchId`, `warnings`, and `usage` from Parallel are passed through when + present +- OpenClaw always forwards a resolved result count to Parallel as + `advanced_settings.max_results`. The caller's `count` arg wins, then the + top-level `tools.web.search.maxResults` setting, otherwise OpenClaw's + generic `web_search` default (5). This keeps result volume consistent + when switching between providers; Parallel on its own defaults to 10 +- Results are cached for 15 minutes by default (configurable via + `cacheTtlMinutes`) + +## Related + +- [Web Search overview](/tools/web) -- all providers and auto-detection +- [Exa search](/tools/exa-search) -- neural search with content extraction +- [Perplexity Search](/tools/perplexity-search) -- structured results with domain filtering diff --git a/docs/tools/web.md b/docs/tools/web.md index 9a958c416038..e107e3937512 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -84,6 +84,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. Search via a signed-in local Ollama host or the hosted Ollama API. + + LLM-optimized dense excerpts from a web index purpose-built for AI agents. + Structured results with content extraction controls and domain filtering. @@ -108,6 +111,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. | [Kimi](/tools/kimi-search) | AI-synthesized + citations; fails on ungrounded chat fallbacks | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | | [MiniMax Search](/tools/minimax-search) | Structured snippets | Region (`global` / `cn`) | `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` / `MINIMAX_OAUTH_TOKEN` | | [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None for signed-in local hosts; `OLLAMA_API_KEY` for direct `https://ollama.com` search | +| [Parallel](/tools/parallel-search) | Dense excerpts ranked for LLM context | -- | `PARALLEL_API_KEY` | | [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | | [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) | | [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` | @@ -184,12 +188,13 @@ API-backed providers first: 7. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` (order 60) 8. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`; optional `plugins.entries.exa.config.webSearch.baseUrl` overrides the Exa endpoint (order 65) 9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70) +10. **Parallel** -- `PARALLEL_API_KEY` or `plugins.entries.parallel.config.webSearch.apiKey`; optional `plugins.entries.parallel.config.webSearch.baseUrl` overrides the Parallel endpoint (order 75) Key-free fallbacks after that: -10. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100) -11. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110) -12. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200) +11. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100) +12. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110) +13. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200) If no provider is detected, it falls back to Brave (you will get a missing-key error prompting you to configure one). @@ -198,7 +203,7 @@ error prompting you to configure one). All provider key fields support SecretRef objects. Plugin-scoped SecretRefs under `plugins.entries..config.webSearch.apiKey` are resolved for the bundled API-backed web search providers, including Brave, Exa, Firecrawl, - Gemini, Grok, Kimi, MiniMax, Perplexity, and Tavily, + Gemini, Grok, Kimi, MiniMax, Parallel, Perplexity, and Tavily, whether the provider is picked explicitly via `tools.web.search.provider` or selected through auto-detect. In auto-detect mode, OpenClaw resolves only the selected provider key -- non-selected SecretRefs stay inactive, so you can diff --git a/extensions/parallel/index.ts b/extensions/parallel/index.ts new file mode 100644 index 000000000000..a8871aa469d7 --- /dev/null +++ b/extensions/parallel/index.ts @@ -0,0 +1,11 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js"; + +export default definePluginEntry({ + id: "parallel", + name: "Parallel Plugin", + description: "Bundled Parallel web search plugin", + register(api) { + api.registerWebSearchProvider(createParallelWebSearchProvider()); + }, +}); diff --git a/extensions/parallel/openclaw.plugin.json b/extensions/parallel/openclaw.plugin.json new file mode 100644 index 000000000000..13fd9e978da3 --- /dev/null +++ b/extensions/parallel/openclaw.plugin.json @@ -0,0 +1,50 @@ +{ + "id": "parallel", + "activation": { + "onStartup": false + }, + "setup": { + "providers": [ + { + "id": "parallel", + "envVars": ["PARALLEL_API_KEY"] + } + ] + }, + "uiHints": { + "webSearch.apiKey": { + "label": "Parallel API Key", + "help": "Parallel Search API key (fallback: PARALLEL_API_KEY env var).", + "sensitive": true, + "placeholder": "par-..." + }, + "webSearch.baseUrl": { + "label": "Parallel Search Base URL", + "help": "Optional Parallel API base URL override. OpenClaw appends /v1/search when the URL does not already end there." + } + }, + "contracts": { + "webSearchProviders": ["parallel"] + }, + "configContracts": { + "compatibilityRuntimePaths": ["tools.web.search.apiKey"] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "webSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { + "type": ["string", "object"] + }, + "baseUrl": { + "type": "string" + } + } + } + } + } +} diff --git a/extensions/parallel/package.json b/extensions/parallel/package.json new file mode 100644 index 000000000000..6d369e73d118 --- /dev/null +++ b/extensions/parallel/package.json @@ -0,0 +1,15 @@ +{ + "name": "@openclaw/parallel-plugin", + "version": "2026.5.21", + "private": true, + "description": "OpenClaw Parallel web search plugin", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/parallel/parallel.live.test.ts b/extensions/parallel/parallel.live.test.ts new file mode 100644 index 000000000000..c7aef98132ca --- /dev/null +++ b/extensions/parallel/parallel.live.test.ts @@ -0,0 +1,46 @@ +import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js"; + +const PARALLEL_API_KEY = process.env.PARALLEL_API_KEY?.trim() ?? ""; +const describeLive = isLiveTestEnabled() && PARALLEL_API_KEY.length > 0 ? describe : describe.skip; + +const PARALLEL_LIVE_TIMEOUT_MS = 120_000; + +describeLive("parallel plugin live", () => { + it( + "runs Parallel web search through the provider tool", + async () => { + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool?.({ + config: {}, + searchConfig: { parallel: { apiKey: PARALLEL_API_KEY } }, + }); + if (!tool) { + throw new Error("Expected Parallel provider tool"); + } + + const result = (await tool.execute({ + objective: + "Find the OpenClaw GitHub repository and recent project activity for a quick smoke test.", + search_queries: ["openclaw github repository", "openclaw release notes"], + count: 3, + client_model: "claude-opus-4-7", + })) as { + provider?: string; + count?: number; + results?: Array<{ url?: string; title?: string }>; + sessionId?: string; + }; + + expect(result.provider).toBe("parallel"); + expect(typeof result.count).toBe("number"); + expect(Array.isArray(result.results)).toBe(true); + expect((result.results ?? []).length).toBeGreaterThan(0); + const first = result.results?.[0]; + expect((first?.url ?? "").startsWith("http")).toBe(true); + expect(typeof result.sessionId).toBe("string"); + }, + PARALLEL_LIVE_TIMEOUT_MS, + ); +}); diff --git a/extensions/parallel/src/parallel-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-web-search-provider.runtime.ts new file mode 100644 index 000000000000..507e76d1a566 --- /dev/null +++ b/extensions/parallel/src/parallel-web-search-provider.runtime.ts @@ -0,0 +1,443 @@ +import { createRequire } from "node:module"; +import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + mergeScopedSearchConfig, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringArrayParam, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchTimeoutSeconds, + resolveSiteName, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const PARALLEL_BASE_URL = "https://api.parallel.ai"; +const PARALLEL_SEARCH_PATHNAME = "/v1/search"; +const PARALLEL_MAX_SEARCH_COUNT = 40; +// Parallel v1 Search caps each search_queries entry at 200 chars, the +// objective field at 5000, and accepts up to 5 search queries. See +// https://docs.parallel.ai/search/best-practices. +const PARALLEL_MAX_SEARCH_QUERY_CHARS = 200; +const PARALLEL_MAX_OBJECTIVE_CHARS = 5000; +const PARALLEL_MAX_SEARCH_QUERIES = 5; +const PARALLEL_SESSION_ID_MAX_LENGTH = 1000; +const PARALLEL_CLIENT_MODEL_MAX_LENGTH = 100; + +const require = createRequire(import.meta.url); +const PLUGIN_VERSION = readPluginPackageVersion({ require }); +const USER_AGENT = `openclaw-parallel/${PLUGIN_VERSION} (${process.platform})`; + +type ParallelConfig = { + apiKey?: string; + baseUrl?: string; +}; + +type ParallelSearchResult = { + title?: unknown; + url?: unknown; + publish_date?: unknown; + excerpts?: unknown; +}; + +type ParallelSearchResponse = { + search_id?: unknown; + session_id?: unknown; + results?: unknown; + warnings?: unknown; + usage?: unknown; +}; + +function resolveParallelConfig(searchConfig?: SearchConfigRecord): ParallelConfig { + const parallel = searchConfig?.parallel; + return parallel && typeof parallel === "object" && !Array.isArray(parallel) + ? (parallel as ParallelConfig) + : {}; +} + +function resolveParallelApiKey(parallel?: ParallelConfig): string | undefined { + return ( + readConfiguredSecretString(parallel?.apiKey, "tools.web.search.parallel.apiKey") ?? + readProviderEnvValue(["PARALLEL_API_KEY"]) + ); +} + +function invalidBaseUrlPayload(value: string) { + return { + error: "invalid_base_url", + message: `plugins.entries.parallel.config.webSearch.baseUrl must be a valid http(s) URL. Got: ${value}`, + docs: "https://docs.openclaw.ai/tools/parallel-search", + }; +} + +function resolveParallelSearchEndpoint( + parallel?: ParallelConfig, +): { endpoint: string } | { error: string; message: string; docs: string } { + const configured = normalizeOptionalString(parallel?.baseUrl); + if (!configured) { + return { endpoint: `${PARALLEL_BASE_URL}${PARALLEL_SEARCH_PATHNAME}` }; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) && !/^https?:\/\//i.test(configured)) { + return invalidBaseUrlPayload(configured); + } + const candidate = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return invalidBaseUrlPayload(configured); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return invalidBaseUrlPayload(configured); + } + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.pathname = pathname.endsWith(PARALLEL_SEARCH_PATHNAME) + ? pathname + : `${pathname === "" ? "" : pathname}${PARALLEL_SEARCH_PATHNAME}`; + parsed.hash = ""; + return { endpoint: parsed.toString() }; +} + +function resolveParallelSearchCount(value: number): number { + return Math.max(1, Math.min(PARALLEL_MAX_SEARCH_COUNT, Math.floor(value))); +} + +function normalizeParallelSessionId(value: string | undefined): string | undefined { + const trimmed = normalizeOptionalString(value); + return trimmed && trimmed.length <= PARALLEL_SESSION_ID_MAX_LENGTH ? trimmed : undefined; +} + +function normalizeParallelObjective(value: string | undefined): string | undefined { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return undefined; + } + return trimmed.length <= PARALLEL_MAX_OBJECTIVE_CHARS + ? trimmed + : trimmed.slice(0, PARALLEL_MAX_OBJECTIVE_CHARS); +} + +function normalizeParallelClientModel(value: string | undefined): string | undefined { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return undefined; + } + return trimmed.length <= PARALLEL_CLIENT_MODEL_MAX_LENGTH + ? trimmed + : trimmed.slice(0, PARALLEL_CLIENT_MODEL_MAX_LENGTH); +} + +// Parallel's API caps each entry at 200 chars and accepts up to 5 queries. +// We trim, drop empties/duplicates, truncate over-long entries to the API's +// hard limit, and cap to the API's maximum so a malformed call from the +// model doesn't 422 the request. See https://docs.parallel.ai/search/best-practices. +function normalizeParallelSearchQueries(value: unknown): string[] { + const candidates = Array.isArray(value) ? value : []; + const seen = new Set(); + const out: string[] = []; + for (const entry of candidates) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const capped = + trimmed.length <= PARALLEL_MAX_SEARCH_QUERY_CHARS + ? trimmed + : trimmed.slice(0, PARALLEL_MAX_SEARCH_QUERY_CHARS); + if (seen.has(capped)) { + continue; + } + seen.add(capped); + out.push(capped); + if (out.length === PARALLEL_MAX_SEARCH_QUERIES) { + break; + } + } + return out; +} + +function invalidSearchQueriesPayload() { + return { + error: "invalid_search_queries", + message: + "search_queries must be a non-empty array of keyword strings (max 5, max 200 chars each). See https://docs.parallel.ai/search/best-practices.", + docs: "https://docs.openclaw.ai/tools/parallel-search", + }; +} + +function normalizeParallelResults(payload: unknown): ParallelSearchResult[] { + if (!payload || typeof payload !== "object") { + return []; + } + const results = (payload as ParallelSearchResponse).results; + if (!Array.isArray(results)) { + return []; + } + return results.filter((entry): entry is ParallelSearchResult => + Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), + ); +} + +function buildParallelCacheKey(params: { + endpoint: string; + objective?: string; + searchQueries: readonly string[]; + count: number; + sessionId?: string; + clientModel?: string; +}): string { + return buildSearchCacheKey([ + "parallel", + params.endpoint, + params.objective, + // Search queries are already normalized (trimmed, deduped, length-capped, + // ≤5) before reaching the cache key so the join is stable. + params.searchQueries.join(""), + params.count, + // Different Parallel sessions can return different ranked excerpts for + // the same query set, so partition cached payloads by caller-provided + // session. + params.sessionId, + // Parallel tailors defaults/optimizations to client_model per its docs, + // so partition cached payloads by it; otherwise two models hitting the + // same query inside the cache TTL would silently share ranked excerpts. + params.clientModel, + ]); +} + +function missingParallelKeyPayload() { + return { + error: "missing_parallel_api_key", + message: + "web_search (parallel) needs a Parallel API key. Set PARALLEL_API_KEY in the Gateway environment, or configure plugins.entries.parallel.config.webSearch.apiKey.", + docs: "https://docs.openclaw.ai/tools/parallel-search", + }; +} + +async function runParallelSearch(params: { + apiKey: string; + endpoint: string; + objective?: string; + searchQueries: readonly string[]; + maxResults: number; + sessionId?: string; + clientModel?: string; + timeoutSeconds: number; +}): Promise { + const body: Record = { + search_queries: [...params.searchQueries], + advanced_settings: { max_results: params.maxResults }, + }; + if (params.objective) { + body.objective = params.objective; + } + if (params.sessionId) { + body.session_id = params.sessionId; + } + if (params.clientModel) { + body.client_model = params.clientModel; + } + + return withTrustedWebSearchEndpoint( + { + url: params.endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "User-Agent": USER_AGENT, + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`); + } + try { + return (await res.json()) as ParallelSearchResponse; + } catch (cause) { + throw new Error("Parallel API returned malformed JSON", { cause }); + } + }, + ); +} + +export async function executeParallelWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "parallel", + resolveProviderWebSearchPluginConfig(ctx.config, "parallel"), + ) as SearchConfigRecord | undefined; + const parallelConfig = resolveParallelConfig(searchConfig); + const apiKey = resolveParallelApiKey(parallelConfig); + if (!apiKey) { + return missingParallelKeyPayload(); + } + const endpointResult = resolveParallelSearchEndpoint(parallelConfig); + if ("error" in endpointResult) { + return endpointResult; + } + const endpoint = endpointResult.endpoint; + + // Generic `query` arg fallback: openclaw's operator-facing CLI + // (`openclaw capability web.search ...`) always passes the shared + // lowest-common-denominator shape `{ query, count, limit }` to whatever + // provider is active and doesn't know about Parallel's richer + // `{ objective, search_queries }` schema. When `search_queries` is absent + // we promote `query` into the lone search query. `objective` stays unset + // in that case rather than being faked from the keyword string — Parallel + // documents `objective` as natural-language intent and treats it as + // optional, so leaving it absent is more honest than reusing the keyword. + // Agent callers that supply `objective`/`search_queries` explicitly take + // precedence, and the documented schema still requires the native pair so + // the model is encouraged to provide both. + const objective = normalizeParallelObjective(readStringParam(args, "objective")); + const cliQuery = normalizeParallelObjective(readStringParam(args, "query")); + let searchQueries = normalizeParallelSearchQueries(readStringArrayParam(args, "search_queries")); + if (searchQueries.length === 0 && cliQuery) { + searchQueries = normalizeParallelSearchQueries([cliQuery]); + } + if (searchQueries.length === 0) { + return invalidSearchQueriesPayload(); + } + const requestedCount = + readNumberParam(args, "count", { integer: true }) ?? + (typeof searchConfig?.maxResults === "number" ? searchConfig.maxResults : undefined); + // Always pass max_results so Parallel matches the openclaw web_search + // default of 5 instead of falling back to Parallel's own default of 10. + // Switching providers should not silently change result volume / token cost. + const count = resolveParallelSearchCount(requestedCount ?? DEFAULT_SEARCH_COUNT); + const sessionId = normalizeParallelSessionId(readStringParam(args, "session_id")); + const clientModel = normalizeParallelClientModel(readStringParam(args, "client_model")); + const cacheKey = buildParallelCacheKey({ + endpoint, + objective, + searchQueries, + count, + sessionId, + clientModel, + }); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const response = await runParallelSearch({ + apiKey, + endpoint, + objective, + searchQueries, + maxResults: count, + sessionId, + clientModel, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + const results = normalizeParallelResults(response).map((entry) => { + const title = typeof entry.title === "string" ? entry.title : ""; + const url = typeof entry.url === "string" ? entry.url : ""; + const published = + typeof entry.publish_date === "string" && entry.publish_date ? entry.publish_date : undefined; + const excerpts = Array.isArray(entry.excerpts) + ? entry.excerpts + .filter((e): e is string => typeof e === "string") + .map((e) => wrapWebContent(e, "web_search")) + : []; + const description = excerpts.join("\n\n"); + return Object.assign( + { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description, + siteName: resolveSiteName(url) || undefined, + }, + published ? { published } : {}, + excerpts.length > 0 ? { excerpts } : {}, + ); + }); + + const payload: Record = { + ...(objective ? { objective } : {}), + searchQueries, + provider: "parallel", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "parallel", + wrapped: true, + }, + results, + }; + if (typeof response.search_id === "string") { + payload.searchId = response.search_id; + } + if (typeof response.session_id === "string") { + payload.sessionId = response.session_id; + } + if (Array.isArray(response.warnings) && response.warnings.length > 0) { + payload.warnings = response.warnings; + } + if (Array.isArray(response.usage) && response.usage.length > 0) { + payload.usage = response.usage; + } + + // Don't persist a Parallel-generated session id into the shared cache: + // identical queries from unrelated tasks would otherwise share that id and + // an agent threading `sessionId` into follow-ups would group unrelated + // tasks on Parallel's side. Caller-supplied session ids are already part + // of the cache key, so a cache-hit will only ever return the matching id. + const cachePayload = sessionId ? payload : stripParallelGeneratedSessionId(payload); + writeCachedSearchPayload(cacheKey, cachePayload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +function stripParallelGeneratedSessionId( + payload: Record, +): Record { + if (!("sessionId" in payload)) { + return payload; + } + const { sessionId: _omitted, ...rest } = payload; + void _omitted; + return rest; +} + +export const testing = { + buildParallelCacheKey, + invalidSearchQueriesPayload, + missingParallelKeyPayload, + normalizeParallelClientModel, + normalizeParallelObjective, + normalizeParallelResults, + normalizeParallelSearchQueries, + normalizeParallelSessionId, + resolveParallelApiKey, + resolveParallelConfig, + resolveParallelSearchCount, + resolveParallelSearchEndpoint, + USER_AGENT, +} as const; + +export { testing as __testing }; diff --git a/extensions/parallel/src/parallel-web-search-provider.shared.ts b/extensions/parallel/src/parallel-web-search-provider.shared.ts new file mode 100644 index 000000000000..6eb58563ea93 --- /dev/null +++ b/extensions/parallel/src/parallel-web-search-provider.shared.ts @@ -0,0 +1,26 @@ +import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-contract"; + +const PARALLEL_CREDENTIAL_PATH = "plugins.entries.parallel.config.webSearch.apiKey"; +const PARALLEL_ONBOARDING_SCOPES: Array<"text-inference"> = ["text-inference"]; + +export function createParallelWebSearchProviderBase() { + return { + id: "parallel", + label: "Parallel Search", + hint: "LLM-optimized dense excerpts from web sources", + onboardingScopes: [...PARALLEL_ONBOARDING_SCOPES], + credentialLabel: "Parallel API key", + envVars: ["PARALLEL_API_KEY"], + placeholder: "par-...", + signupUrl: "https://platform.parallel.ai", + docsUrl: "https://docs.openclaw.ai/tools/parallel-search", + autoDetectOrder: 75, + credentialPath: PARALLEL_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: PARALLEL_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "parallel" }, + configuredCredential: { pluginId: "parallel" }, + selectionPluginId: "parallel", + }), + }; +} diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts new file mode 100644 index 000000000000..1373bc2efac4 --- /dev/null +++ b/extensions/parallel/src/parallel-web-search-provider.test.ts @@ -0,0 +1,603 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type EndpointCall = { + url: string; + timeoutSeconds: number; + init: RequestInit; +}; + +const endpointMockState = vi.hoisted(() => ({ + calls: [] as EndpointCall[], + responses: [] as Response[], +})); + +vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => { + const actual = await importOriginal(); + const runEndpoint = async ( + params: EndpointCall, + run: (response: Response) => Promise, + ) => { + endpointMockState.calls.push(params); + const response = endpointMockState.responses.shift(); + if (!response) { + throw new Error("Missing mocked Parallel response."); + } + return await run(response); + }; + return { + ...actual, + withTrustedWebSearchEndpoint: vi.fn(runEndpoint), + }; +}); + +function readMockedBody(call: EndpointCall | undefined): unknown { + if (!call || typeof call.init.body !== "string") { + throw new Error("Expected mocked Parallel request to carry a JSON string body."); + } + return JSON.parse(call.init.body); +} + +import { testing } from "../test-api.js"; +import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js"; +import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js"; + +describe("parallel web search provider", () => { + beforeEach(() => { + endpointMockState.calls = []; + endpointMockState.responses = []; + }); + + it("exposes the expected metadata and selection wiring", () => { + const provider = createParallelWebSearchProvider(); + if (!provider.applySelectionConfig) { + throw new Error("Expected applySelectionConfig to be defined"); + } + const applied = provider.applySelectionConfig({}); + + expect(provider.id).toBe("parallel"); + expect(provider.onboardingScopes).toEqual(["text-inference"]); + expect(provider.credentialPath).toBe("plugins.entries.parallel.config.webSearch.apiKey"); + const pluginEntry = applied.plugins?.entries?.parallel; + if (!pluginEntry) { + throw new Error("expected Parallel plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); + }); + + it("keeps the lightweight contract surface aligned with provider metadata", () => { + const provider = createParallelWebSearchProvider(); + const contractProvider = createContractParallelWebSearchProvider(); + if (!contractProvider.applySelectionConfig) { + throw new Error("Expected contract applySelectionConfig to be defined"); + } + const applied = contractProvider.applySelectionConfig({}); + + expect({ + id: contractProvider.id, + label: contractProvider.label, + hint: contractProvider.hint, + onboardingScopes: contractProvider.onboardingScopes, + credentialLabel: contractProvider.credentialLabel, + envVars: contractProvider.envVars, + placeholder: contractProvider.placeholder, + signupUrl: contractProvider.signupUrl, + docsUrl: contractProvider.docsUrl, + autoDetectOrder: contractProvider.autoDetectOrder, + credentialPath: contractProvider.credentialPath, + }).toEqual({ + id: provider.id, + label: provider.label, + hint: provider.hint, + onboardingScopes: provider.onboardingScopes, + credentialLabel: provider.credentialLabel, + envVars: provider.envVars, + placeholder: provider.placeholder, + signupUrl: provider.signupUrl, + docsUrl: provider.docsUrl, + autoDetectOrder: provider.autoDetectOrder, + credentialPath: provider.credentialPath, + }); + expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull(); + const pluginEntry = applied.plugins?.entries?.parallel; + if (!pluginEntry) { + throw new Error("expected contract Parallel plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); + }); + + it("prefers scoped configured api keys over environment fallbacks", () => { + expect(testing.resolveParallelApiKey({ apiKey: "par-secret" })).toBe("par-secret"); + }); + + it("resolves Parallel search base URL overrides", () => { + expect(testing.resolveParallelSearchEndpoint()).toEqual({ + endpoint: "https://api.parallel.ai/v1/search", + }); + expect( + testing.resolveParallelSearchEndpoint({ baseUrl: "https://proxy.example/parallel" }), + ).toEqual({ + endpoint: "https://proxy.example/parallel/v1/search", + }); + expect( + testing.resolveParallelSearchEndpoint({ baseUrl: "proxy.example/parallel/v1/search/" }), + ).toEqual({ + endpoint: "https://proxy.example/parallel/v1/search", + }); + expect( + testing.resolveParallelSearchEndpoint({ baseUrl: "ftp://proxy.example/parallel" }), + ).toEqual({ + docs: "https://docs.openclaw.ai/tools/parallel-search", + error: "invalid_base_url", + message: + "plugins.entries.parallel.config.webSearch.baseUrl must be a valid http(s) URL. Got: ftp://proxy.example/parallel", + }); + }); + + it("partitions Parallel cache keys by resolved endpoint", () => { + const base = { + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github"], + count: 5, + }; + expect( + testing.buildParallelCacheKey({ + ...base, + endpoint: "https://api.parallel.ai/v1/search", + }), + ).not.toBe( + testing.buildParallelCacheKey({ + ...base, + endpoint: "https://proxy.example/parallel/v1/search", + }), + ); + }); + + it("partitions Parallel cache keys by resolved result count", () => { + const base = { + endpoint: "https://api.parallel.ai/v1/search", + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github"], + }; + expect(testing.buildParallelCacheKey({ ...base, count: 5 })).not.toBe( + testing.buildParallelCacheKey({ ...base, count: 10 }), + ); + }); + + it("partitions Parallel cache keys by objective and by search_queries set", () => { + const base = { + endpoint: "https://api.parallel.ai/v1/search", + count: 5, + }; + expect( + testing.buildParallelCacheKey({ + ...base, + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github"], + }), + ).not.toBe( + testing.buildParallelCacheKey({ + ...base, + objective: "Find the OpenClaw release notes", + searchQueries: ["openclaw github"], + }), + ); + expect( + testing.buildParallelCacheKey({ + ...base, + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github"], + }), + ).not.toBe( + testing.buildParallelCacheKey({ + ...base, + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github", "openclaw repository"], + }), + ); + }); + + it("partitions Parallel cache keys by caller-provided session id", () => { + const base = { + endpoint: "https://api.parallel.ai/v1/search", + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github"], + count: 5, + }; + expect(testing.buildParallelCacheKey({ ...base, sessionId: "session-a" })).not.toBe( + testing.buildParallelCacheKey({ ...base, sessionId: "session-b" }), + ); + expect(testing.buildParallelCacheKey({ ...base })).not.toBe( + testing.buildParallelCacheKey({ ...base, sessionId: "session-a" }), + ); + }); + + it("partitions Parallel cache keys by client_model so per-model results never bleed", () => { + const base = { + endpoint: "https://api.parallel.ai/v1/search", + objective: "Find OpenClaw on GitHub", + searchQueries: ["openclaw github"], + count: 5, + }; + expect(testing.buildParallelCacheKey({ ...base, clientModel: "claude-opus-4-7" })).not.toBe( + testing.buildParallelCacheKey({ ...base, clientModel: "gpt-5.5" }), + ); + expect(testing.buildParallelCacheKey({ ...base })).not.toBe( + testing.buildParallelCacheKey({ ...base, clientModel: "claude-opus-4-7" }), + ); + }); + + it("normalizes objectives by trimming and capping at 5000 chars", () => { + expect(testing.normalizeParallelObjective(" Find OpenClaw ")).toBe("Find OpenClaw"); + expect(testing.normalizeParallelObjective(undefined)).toBeUndefined(); + expect(testing.normalizeParallelObjective("")).toBeUndefined(); + expect((testing.normalizeParallelObjective("x".repeat(6000)) ?? "").length).toBe(5000); + }); + + it("normalizes search_queries: trim, drop blanks, dedupe, cap length, cap count", () => { + expect( + testing.normalizeParallelSearchQueries([ + "openclaw github", + " openclaw github ", + "", + " ", + 42, + "openclaw releases", + ]), + ).toEqual(["openclaw github", "openclaw releases"]); + expect(testing.normalizeParallelSearchQueries(undefined)).toEqual([]); + expect(testing.normalizeParallelSearchQueries("openclaw github")).toEqual([]); + expect(testing.normalizeParallelSearchQueries(["x".repeat(250)])).toEqual(["x".repeat(200)]); + const six = ["a", "b", "c", "d", "e", "f"]; + expect(testing.normalizeParallelSearchQueries(six)).toEqual(["a", "b", "c", "d", "e"]); + }); + + it("normalizes session ids, rejecting blanks and overlong values", () => { + expect(testing.normalizeParallelSessionId("session-abc")).toBe("session-abc"); + expect(testing.normalizeParallelSessionId(" ")).toBeUndefined(); + expect(testing.normalizeParallelSessionId(undefined)).toBeUndefined(); + expect(testing.normalizeParallelSessionId("x".repeat(1001))).toBeUndefined(); + }); + + it("normalizes client_model identifiers", () => { + expect(testing.normalizeParallelClientModel("claude-opus-4-7")).toBe("claude-opus-4-7"); + expect(testing.normalizeParallelClientModel(" gpt-5.5 ")).toBe("gpt-5.5"); + expect(testing.normalizeParallelClientModel(undefined)).toBeUndefined(); + expect((testing.normalizeParallelClientModel("a".repeat(200)) ?? "").length).toBe(100); + }); + + it("normalizes the Parallel /v1/search response shape", () => { + expect( + testing.normalizeParallelResults({ + results: [ + { + url: "https://example.com/a", + title: "Sample", + publish_date: "2026-04-01", + excerpts: ["first", "second"], + }, + "not-an-object", + ], + }), + ).toEqual([ + { + url: "https://example.com/a", + title: "Sample", + publish_date: "2026-04-01", + excerpts: ["first", "second"], + }, + ]); + expect(testing.normalizeParallelResults({})).toEqual([]); + expect(testing.normalizeParallelResults(null)).toEqual([]); + }); + + it("clamps Parallel result counts to the documented 1-40 range", () => { + expect(testing.resolveParallelSearchCount(5)).toBe(5); + expect(testing.resolveParallelSearchCount(120)).toBe(40); + expect(testing.resolveParallelSearchCount(0)).toBe(1); + }); + + it("returns a stable missing-key payload that points at the real config path", () => { + expect(testing.missingParallelKeyPayload()).toEqual({ + error: "missing_parallel_api_key", + message: + "web_search (parallel) needs a Parallel API key. Set PARALLEL_API_KEY in the Gateway environment, or configure plugins.entries.parallel.config.webSearch.apiKey.", + docs: "https://docs.openclaw.ai/tools/parallel-search", + }); + }); + + it("identifies the plugin via a versioned User-Agent header", () => { + expect(testing.USER_AGENT).toMatch(/^openclaw-parallel\/\d+\.\d+\.\d+/); + }); + + it("treats objective as optional and omits it from the request when absent", async () => { + // Parallel's `/v1/search` API documents `objective` as `string | null`. + // When agent callers only supply `search_queries`, the runtime should not + // synthesize an objective from the keyword phrase (that would misrepresent + // intent); it should simply leave the field out of the request body. + endpointMockState.responses.push( + new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const result = await tool.execute({ search_queries: ["openclaw"] }); + expect(endpointMockState.calls).toHaveLength(1); + const body = readMockedBody(endpointMockState.calls[0]) as Record; + expect(body).not.toHaveProperty("objective"); + expect(body).toMatchObject({ search_queries: ["openclaw"] }); + expect(result).not.toHaveProperty("objective"); + expect(result).toMatchObject({ provider: "parallel" }); + }); + + it("returns an error payload when search_queries is missing or empty", async () => { + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + expect(await tool.execute({ objective: "Find OpenClaw on GitHub" })).toMatchObject({ + error: "invalid_search_queries", + }); + expect( + await tool.execute({ objective: "Find OpenClaw on GitHub", search_queries: [] }), + ).toMatchObject({ error: "invalid_search_queries" }); + expect(endpointMockState.calls).toHaveLength(0); + }); + + it("promotes a generic `query` arg into search_queries when search_queries is absent (no synthesized objective)", async () => { + // The operator CLI (`openclaw capability web.search`) always sends the + // shared lowest-common-denominator shape `{ query, count, limit }` and + // doesn't know about provider-specific schemas. The runtime promotes + // `query` into the lone `search_queries` entry so that CLI keeps working + // when Parallel is the active provider. `objective` is *not* synthesized + // from the keyword phrase — Parallel treats it as optional natural-language + // intent and reusing a keyword as objective would misrepresent intent. + endpointMockState.responses.push( + new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const result = await tool.execute({ query: "OpenClaw GitHub", count: 3 }); + expect(endpointMockState.calls).toHaveLength(1); + const body = readMockedBody(endpointMockState.calls[0]) as Record; + expect(body).not.toHaveProperty("objective"); + expect(body).toMatchObject({ + search_queries: ["OpenClaw GitHub"], + advanced_settings: { max_results: 3 }, + }); + expect(result).not.toHaveProperty("objective"); + expect(result).toMatchObject({ provider: "parallel" }); + }); + + it("prefers explicit objective+search_queries over the generic `query` fallback when all are present", async () => { + endpointMockState.responses.push( + new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + await tool.execute({ + objective: "Native objective", + search_queries: ["native query"], + query: "legacy fallback", + }); + const body = readMockedBody(endpointMockState.calls[0]) as Record; + expect(body).toMatchObject({ + objective: "Native objective", + search_queries: ["native query"], + }); + }); + + it("honors top-level web search settings and sends the native Parallel payload shape", async () => { + endpointMockState.responses.push( + new Response( + JSON.stringify({ + search_id: "search_test", + session_id: "session_test", + results: [{ url: "https://example.com/a", title: "A", excerpts: ["alpha"] }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { + parallel: { apiKey: "par-secret" }, + maxResults: 3, + timeoutSeconds: 5, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const result = await tool.execute({ + objective: "Find the OpenClaw repository on GitHub", + search_queries: ["openclaw github", "openclaw repository"], + }); + + expect(endpointMockState.calls).toHaveLength(1); + const [call] = endpointMockState.calls; + expect(call.url).toBe("https://api.parallel.ai/v1/search"); + expect(call.timeoutSeconds).toBe(5); + expect(readMockedBody(call)).toEqual({ + objective: "Find the OpenClaw repository on GitHub", + search_queries: ["openclaw github", "openclaw repository"], + advanced_settings: { max_results: 3 }, + }); + const headers = (call.init.headers ?? {}) as Record; + expect(headers["x-api-key"]).toBe("par-secret"); + expect(headers["User-Agent"]).toMatch(/^openclaw-parallel\//); + expect(result).toMatchObject({ + provider: "parallel", + searchId: "search_test", + sessionId: "session_test", + }); + }); + + it("threads caller-supplied session_id and client_model through to Parallel", async () => { + endpointMockState.responses.push( + new Response( + JSON.stringify({ + search_id: "search_test", + session_id: "session-caller-supplied", + results: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const result = await tool.execute({ + objective: "Find the OpenClaw repository on GitHub", + search_queries: ["openclaw github"], + session_id: "session-caller-supplied", + client_model: "claude-opus-4-7", + }); + const body = readMockedBody(endpointMockState.calls[0]) as Record; + expect(body).toMatchObject({ + objective: "Find the OpenClaw repository on GitHub", + search_queries: ["openclaw github"], + session_id: "session-caller-supplied", + client_model: "claude-opus-4-7", + }); + expect(result).toMatchObject({ sessionId: "session-caller-supplied" }); + }); + + it("always sends max_results matching the OpenClaw web_search default when no count is provided", async () => { + endpointMockState.responses.push( + new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + await tool.execute({ + objective: "Find OpenClaw", + search_queries: ["openclaw"], + }); + expect(endpointMockState.calls).toHaveLength(1); + const body = readMockedBody(endpointMockState.calls[0]) as { + advanced_settings?: { max_results?: number }; + }; + // OpenClaw's web_search default is 5 results; Parallel's own default is 10. + // Sending an explicit max_results keeps result volume consistent across providers. + expect(body.advanced_settings?.max_results).toBe(5); + }); + + it("does not surface a Parallel-generated sessionId on a cache hit", async () => { + // Unique objective so this test does not collide with the SDK's + // module-level web-search cache across other cases. + const objective = `parallel-cache-isolation-${Date.now()}-${Math.random()}`; + endpointMockState.responses.push( + new Response( + JSON.stringify({ + search_id: "first", + session_id: "session-generated-by-parallel", + results: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const firstResult = (await tool.execute({ + objective, + search_queries: ["openclaw github"], + })) as { sessionId?: string }; + expect(firstResult.sessionId).toBe("session-generated-by-parallel"); + + // Second identical call without a caller-supplied session_id must hit the + // cache (no second HTTP call) and must NOT leak the first task's + // auto-generated sessionId — otherwise an agent threading it back into + // follow-up calls would group unrelated tasks on Parallel's side. + const secondResult = (await tool.execute({ + objective, + search_queries: ["openclaw github"], + })) as { sessionId?: string }; + expect(endpointMockState.calls).toHaveLength(1); + expect(secondResult.sessionId).toBeUndefined(); + }); + + it("preserves caller-supplied sessionId across cache hits", async () => { + const objective = `parallel-cache-session-${Date.now()}-${Math.random()}`; + const sessionId = `session-${Date.now()}`; + endpointMockState.responses.push( + new Response( + JSON.stringify({ + search_id: "first", + session_id: sessionId, + results: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + await tool.execute({ + objective, + search_queries: ["openclaw github"], + session_id: sessionId, + }); + const cached = (await tool.execute({ + objective, + search_queries: ["openclaw github"], + session_id: sessionId, + })) as { sessionId?: string }; + expect(endpointMockState.calls).toHaveLength(1); + expect(cached.sessionId).toBe(sessionId); + }); +}); diff --git a/extensions/parallel/src/parallel-web-search-provider.ts b/extensions/parallel/src/parallel-web-search-provider.ts new file mode 100644 index 000000000000..be29de8f416a --- /dev/null +++ b/extensions/parallel/src/parallel-web-search-provider.ts @@ -0,0 +1,75 @@ +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; +import { createParallelWebSearchProviderBase } from "./parallel-web-search-provider.shared.js"; + +const PARALLEL_MAX_SEARCH_COUNT = 40; +const PARALLEL_MAX_SEARCH_QUERIES = 5; +const PARALLEL_MAX_SEARCH_QUERY_CHARS = 200; +const PARALLEL_MAX_OBJECTIVE_CHARS = 5000; +const PARALLEL_MAX_SESSION_ID_CHARS = 1000; +const PARALLEL_MAX_CLIENT_MODEL_CHARS = 100; + +type ParallelWebSearchRuntime = typeof import("./parallel-web-search-provider.runtime.js"); + +let parallelWebSearchRuntimePromise: Promise | undefined; + +function loadParallelWebSearchRuntime(): Promise { + parallelWebSearchRuntimePromise ??= import("./parallel-web-search-provider.runtime.js"); + return parallelWebSearchRuntimePromise; +} + +// Mirrors Parallel's recommended search tool schema: +// https://docs.parallel.ai/search/best-practices#search-tool-definition +const ParallelSearchSchema = { + type: "object", + properties: { + objective: { + type: "string", + description: + "Natural-language description of the underlying question or goal driving the search. Should be self-contained with enough context to understand the intent. Used together with search_queries to focus results on the most relevant content.", + maxLength: PARALLEL_MAX_OBJECTIVE_CHARS, + }, + search_queries: { + type: "array", + description: + "Concise keyword search queries, 3-6 words each. Provide 2-3 diverse queries for best results (max 5). Vary entity names, synonyms, and angles. Each query is a keyword phrase, not a sentence; do not use site: operators.", + items: { type: "string", maxLength: PARALLEL_MAX_SEARCH_QUERY_CHARS }, + minItems: 1, + maxItems: PARALLEL_MAX_SEARCH_QUERIES, + }, + count: { + type: "integer", + description: "Number of results to return (1-40).", + minimum: 1, + maximum: PARALLEL_MAX_SEARCH_COUNT, + }, + session_id: { + type: "string", + description: + "Optional session id returned by an earlier Parallel search. Pass it on follow-up searches that are part of the same task to keep Parallel's server-side context grouped (look for `sessionId` in the prior tool result).", + maxLength: PARALLEL_MAX_SESSION_ID_CHARS, + }, + client_model: { + type: "string", + description: + "The identifier of the LLM model making this tool call (e.g. 'claude-opus-4-7', 'gpt-5.5', 'gemini-3.1-pro'). Pass the exact active model slug verbatim; never shorten or substitute a family alias like 'gpt-5'. Lets Parallel tailor default settings for your model's capabilities.", + maxLength: PARALLEL_MAX_CLIENT_MODEL_CHARS, + }, + }, + required: ["objective", "search_queries"], + additionalProperties: false, +} satisfies Record; + +export function createParallelWebSearchProvider(): WebSearchProviderPlugin { + return { + ...createParallelWebSearchProviderBase(), + createTool: (ctx) => ({ + description: + "Search the web using Parallel. Returns ranked, LLM-optimized dense excerpts from web sources. Pass an `objective` describing the underlying question along with 2-3 short keyword `search_queries` (Parallel's recommended pairing). For multi-step research, thread the prior result's `sessionId` back in as `session_id` to keep Parallel's context grouped.", + parameters: ParallelSearchSchema, + execute: async (args) => { + const { executeParallelWebSearchProviderTool } = await loadParallelWebSearchRuntime(); + return await executeParallelWebSearchProviderTool(ctx, args); + }, + }), + }; +} diff --git a/extensions/parallel/test-api.ts b/extensions/parallel/test-api.ts new file mode 100644 index 000000000000..2eba18dd2b63 --- /dev/null +++ b/extensions/parallel/test-api.ts @@ -0,0 +1 @@ +export { testing, testing as __testing } from "./src/parallel-web-search-provider.runtime.js"; diff --git a/extensions/parallel/tsconfig.json b/extensions/parallel/tsconfig.json new file mode 100644 index 000000000000..b8a85a99ac3d --- /dev/null +++ b/extensions/parallel/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.package-boundary.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts", "./src/**/*.ts"], + "exclude": [ + "./**/*.test.ts", + "./dist/**", + "./node_modules/**", + "./src/test-support/**", + "./src/**/*test-helpers.ts", + "./src/**/*test-harness.ts", + "./src/**/*test-support.ts" + ] +} diff --git a/extensions/parallel/web-search-contract-api.ts b/extensions/parallel/web-search-contract-api.ts new file mode 100644 index 000000000000..f75f2451fd8d --- /dev/null +++ b/extensions/parallel/web-search-contract-api.ts @@ -0,0 +1,9 @@ +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; +import { createParallelWebSearchProviderBase } from "./src/parallel-web-search-provider.shared.js"; + +export function createParallelWebSearchProvider(): WebSearchProviderPlugin { + return { + ...createParallelWebSearchProviderBase(), + createTool: () => null, + }; +} diff --git a/extensions/parallel/web-search-provider.ts b/extensions/parallel/web-search-provider.ts new file mode 100644 index 000000000000..09ebf81c9d04 --- /dev/null +++ b/extensions/parallel/web-search-provider.ts @@ -0,0 +1 @@ +export { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea76561a2ebf..274643e64493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1262,6 +1262,12 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/parallel: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + extensions/perplexity: devDependencies: '@openclaw/plugin-sdk': diff --git a/src/agents/tool-display-common.ts b/src/agents/tool-display-common.ts index 71493e41ac68..a886e9326c2a 100644 --- a/src/agents/tool-display-common.ts +++ b/src/agents/tool-display-common.ts @@ -350,8 +350,13 @@ function collectWebSearchQueries(record: Record): string[] { add(record.q); add(record.search); add(record.input); + // Parallel's `web_search` provider uses the native Parallel Search shape + // (`objective` + `search_queries`). Surface those so CLI progress and + // Codex activity metadata render the query context instead of a bare + // `search`. + add(record.objective); - for (const key of ["search_query", "image_query", "queries"]) { + for (const key of ["search_query", "image_query", "queries", "search_queries"]) { const value = record[key]; if (!Array.isArray(value)) { continue; diff --git a/src/agents/tool-display.test.ts b/src/agents/tool-display.test.ts index 0171dd351c3e..1ad63abd75bd 100644 --- a/src/agents/tool-display.test.ts +++ b/src/agents/tool-display.test.ts @@ -162,6 +162,23 @@ describe("tool display details", () => { ).toBe('for "latest Kimi model", "latest Gemini model", "latest Claude model"…'); }); + it("formats Parallel's native objective + search_queries shape", () => { + expect( + formatToolDetail( + resolveToolDisplay({ + name: "web_search", + args: { + objective: "Find the OpenClaw repository on GitHub", + search_queries: ["openclaw github", "openclaw repository"], + count: 5, + }, + }), + ), + ).toBe( + 'for "Find the OpenClaw repository on GitHub", "openclaw github", "openclaw repository" (top 5)', + ); + }); + it("summarizes exec commands with context", () => { const detail = formatToolDetail( resolveToolDisplay({ diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts index e6457c82832d..1194569e23d8 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.ts @@ -20,14 +20,15 @@ const BUNDLED_LEGACY_WEB_SEARCH_OWNERS = new Map([ ["kimi", "moonshot"], ["minimax", "minimax"], ["ollama", "ollama"], + ["parallel", "parallel"], ["perplexity", "perplexity"], ["searxng", "searxng"], ["tavily", "tavily"], ]); -// Tavily only ever used the plugin-owned config path, so there is no legacy -// `tools.web.search.tavily.*` shape to migrate. -const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["tavily"]); +// Tavily and Parallel only ever used the plugin-owned config path, so there is +// no legacy `tools.web.search..*` shape to migrate for them. +const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["parallel", "tavily"]); const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave"; function getBundledLegacyWebSearchOwners(): ReadonlyMap { diff --git a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts index 252bd6bedf6e..16a0211fbfa5 100644 --- a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts +++ b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts @@ -185,6 +185,10 @@ export const pluginRegistrationContractCases = { requireGenerateImage: true, requireGenerateVideo: true, }, + parallel: { + pluginId: "parallel", + webSearchProviderIds: ["parallel"], + }, perplexity: { pluginId: "perplexity", webSearchProviderIds: ["perplexity"], diff --git a/src/plugins/contracts/plugin-registration.parallel.contract.test.ts b/src/plugins/contracts/plugin-registration.parallel.contract.test.ts new file mode 100644 index 000000000000..da9b0ac631a7 --- /dev/null +++ b/src/plugins/contracts/plugin-registration.parallel.contract.test.ts @@ -0,0 +1,4 @@ +import { pluginRegistrationContractCases } from "openclaw/plugin-sdk/plugin-test-contracts"; +import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts"; + +describePluginRegistrationContract(pluginRegistrationContractCases.parallel); diff --git a/src/plugins/contracts/providers.contract.test.ts b/src/plugins/contracts/providers.contract.test.ts index d529aaa865d5..51ec10ea2a99 100644 --- a/src/plugins/contracts/providers.contract.test.ts +++ b/src/plugins/contracts/providers.contract.test.ts @@ -23,6 +23,7 @@ for (const providerId of [ "google", "minimax", "moonshot", + "parallel", "perplexity", "tavily", "xai", diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 293ebbc43e0c..395f346ab3bf 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -21,6 +21,7 @@ const BUNDLED_WEB_SEARCH_PROVIDERS = [ { pluginId: "firecrawl", id: "firecrawl", order: 60 }, { pluginId: "exa", id: "exa", order: 65 }, { pluginId: "tavily", id: "tavily", order: 70 }, + { pluginId: "parallel", id: "parallel", order: 75 }, { pluginId: "duckduckgo", id: "duckduckgo", order: 100 }, ] as const; @@ -51,6 +52,7 @@ const EXPECTED_BUNDLED_RUNTIME_WEB_SEARCH_PROVIDER_KEYS = [ "google:gemini", "xai:grok", "moonshot:kimi", + "parallel:parallel", "perplexity:perplexity", "tavily:tavily", ] as const; diff --git a/test/vitest/vitest.extension-misc-paths.mjs b/test/vitest/vitest.extension-misc-paths.mjs index 01a14177104e..85e3cde88e4e 100644 --- a/test/vitest/vitest.extension-misc-paths.mjs +++ b/test/vitest/vitest.extension-misc-paths.mjs @@ -15,6 +15,7 @@ export const miscExtensionTestRoots = [ "extensions/opencode", "extensions/opencode-go", "extensions/openshell", + "extensions/parallel", "extensions/perplexity", "extensions/phone-control", "extensions/searxng",