mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(parallel): add Parallel as a bundled web_search provider (#85158)
- New extensions/parallel package modeled on extensions/exa
- Wires Parallel's POST /v1/search through the generic web_search contract,
exposing Parallel's recommended {objective, search_queries} shape (plus
optional count, session_id, client_model) so the model can supply both the
natural-language goal and 2-3 short keyword queries as Parallel docs advise
- client_model lets the model report its own slug so Parallel can tailor
optimizations for the consuming model's capabilities; partitions the cache
by client_model so different models do not silently share ranked excerpts
- Honors top-level tools.web.search.{maxResults,timeoutSeconds,cacheTtlMinutes}
via the shared SDK helpers (mergeScopedSearchConfig, withTrustedWebSearchEndpoint,
buildSearchCacheKey, read/writeCachedSearchPayload)
- Auto-detect order 75; auth via PARALLEL_API_KEY or
plugins.entries.parallel.config.webSearch.apiKey
- Optional baseUrl override for proxies (e.g. Cloudflare AI Gateway)
- Threads caller-supplied session_id through follow-up calls; strips
auto-generated session_id from the shared cache to avoid cross-task leaks
- Always sends advanced_settings.max_results so result volume matches the
OpenClaw web_search default (5) instead of Parallel's default (10)
- Identifies the plugin via User-Agent header built from package version
- Runtime accepts the generic `query` arg as a fallback so the operator
CLI (openclaw capability web.search) keeps working when Parallel is the
active provider: it is promoted into the lone `search_queries` entry.
`objective` stays optional and is never synthesized from a keyword
query (Parallel documents it as natural-language intent). Agent callers
using the native objective+search_queries shape take precedence; the
schema still advertises only the native keys
- Updates the agent tool-display extractor (src/agents/tool-display-common.ts)
to recognize Parallel's objective+search_queries shape so calls render with
query context in CLI progress and Codex activity metadata
- Adds /tools/parallel-search docs page, web.md provider listing, docs nav,
labeler entry, per-plugin registration contract test, and minimal core
touch-points (legacy migrate, registration cases, providers contract list,
runtime bundled list, vitest extension paths)
This commit is contained in:
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -1331,6 +1331,7 @@
|
||||
"tools/kimi-search",
|
||||
"tools/minimax-search",
|
||||
"tools/ollama-search",
|
||||
"tools/parallel-search",
|
||||
"tools/perplexity-search",
|
||||
"tools/searxng-search",
|
||||
"tools/tavily"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
127
docs/tools/parallel-search.md
Normal file
127
docs/tools/parallel-search.md
Normal file
@@ -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
|
||||
|
||||
<Steps>
|
||||
<Step title="Create an account">
|
||||
Sign up at [platform.parallel.ai](https://platform.parallel.ai) and
|
||||
generate an API key from your dashboard.
|
||||
</Step>
|
||||
<Step title="Store the key">
|
||||
Set `PARALLEL_API_KEY` in the Gateway environment, or configure via:
|
||||
|
||||
```bash
|
||||
openclaw configure --section web
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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.
|
||||
|
||||
<ParamField path="objective" type="string" required>
|
||||
Natural-language description of the underlying question or goal (max 5000
|
||||
chars). Should be self-contained.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="search_queries" type="string[]" required>
|
||||
Concise keyword search queries, 3-6 words each (1-5 entries, max 200 chars
|
||||
each). Provide 2-3 diverse queries for best results.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="count" type="number">
|
||||
Results to return (1-40).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="session_id" type="string">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="client_model" type="string">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
## 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
|
||||
@@ -84,6 +84,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
|
||||
<Card title="Ollama Web Search" icon="globe" href="/tools/ollama-search">
|
||||
Search via a signed-in local Ollama host or the hosted Ollama API.
|
||||
</Card>
|
||||
<Card title="Parallel" icon="layer-group" href="/tools/parallel-search">
|
||||
LLM-optimized dense excerpts from a web index purpose-built for AI agents.
|
||||
</Card>
|
||||
<Card title="Perplexity" icon="search" href="/tools/perplexity-search">
|
||||
Structured results with content extraction controls and domain filtering.
|
||||
</Card>
|
||||
@@ -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.<plugin>.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
|
||||
|
||||
11
extensions/parallel/index.ts
Normal file
11
extensions/parallel/index.ts
Normal file
@@ -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());
|
||||
},
|
||||
});
|
||||
50
extensions/parallel/openclaw.plugin.json
Normal file
50
extensions/parallel/openclaw.plugin.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
extensions/parallel/package.json
Normal file
15
extensions/parallel/package.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
46
extensions/parallel/parallel.live.test.ts
Normal file
46
extensions/parallel/parallel.live.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
443
extensions/parallel/src/parallel-web-search-provider.runtime.ts
Normal file
443
extensions/parallel/src/parallel-web-search-provider.runtime.ts
Normal file
@@ -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<string>();
|
||||
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<ParallelSearchResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown> = {
|
||||
...(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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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 };
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
603
extensions/parallel/src/parallel-web-search-provider.test.ts
Normal file
603
extensions/parallel/src/parallel-web-search-provider.test.ts
Normal file
@@ -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<typeof import("openclaw/plugin-sdk/provider-web-search")>();
|
||||
const runEndpoint = async (
|
||||
params: EndpointCall,
|
||||
run: (response: Response) => Promise<unknown>,
|
||||
) => {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, string>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
75
extensions/parallel/src/parallel-web-search-provider.ts
Normal file
75
extensions/parallel/src/parallel-web-search-provider.ts
Normal file
@@ -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<ParallelWebSearchRuntime> | undefined;
|
||||
|
||||
function loadParallelWebSearchRuntime(): Promise<ParallelWebSearchRuntime> {
|
||||
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<string, unknown>;
|
||||
|
||||
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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
1
extensions/parallel/test-api.ts
Normal file
1
extensions/parallel/test-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { testing, testing as __testing } from "./src/parallel-web-search-provider.runtime.js";
|
||||
16
extensions/parallel/tsconfig.json
Normal file
16
extensions/parallel/tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
9
extensions/parallel/web-search-contract-api.ts
Normal file
9
extensions/parallel/web-search-contract-api.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
1
extensions/parallel/web-search-provider.ts
Normal file
1
extensions/parallel/web-search-provider.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js";
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -350,8 +350,13 @@ function collectWebSearchQueries(record: Record<string, unknown>): 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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -20,14 +20,15 @@ const BUNDLED_LEGACY_WEB_SEARCH_OWNERS = new Map<string, string>([
|
||||
["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.<id>.*` 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<string, string> {
|
||||
|
||||
@@ -185,6 +185,10 @@ export const pluginRegistrationContractCases = {
|
||||
requireGenerateImage: true,
|
||||
requireGenerateVideo: true,
|
||||
},
|
||||
parallel: {
|
||||
pluginId: "parallel",
|
||||
webSearchProviderIds: ["parallel"],
|
||||
},
|
||||
perplexity: {
|
||||
pluginId: "perplexity",
|
||||
webSearchProviderIds: ["perplexity"],
|
||||
|
||||
@@ -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);
|
||||
@@ -23,6 +23,7 @@ for (const providerId of [
|
||||
"google",
|
||||
"minimax",
|
||||
"moonshot",
|
||||
"parallel",
|
||||
"perplexity",
|
||||
"tavily",
|
||||
"xai",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const miscExtensionTestRoots = [
|
||||
"extensions/opencode",
|
||||
"extensions/opencode-go",
|
||||
"extensions/openshell",
|
||||
"extensions/parallel",
|
||||
"extensions/perplexity",
|
||||
"extensions/phone-control",
|
||||
"extensions/searxng",
|
||||
|
||||
Reference in New Issue
Block a user