Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchTimeoutSeconds,
|
||||
type SearchConfigRecord,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { PARALLEL_MCP_SEARCH_URL, runParallelMcpSearch } from "./parallel-mcp-search.runtime.js";
|
||||
import {
|
||||
buildParallelCacheKey,
|
||||
invalidSearchQueriesPayload,
|
||||
mapParallelResults,
|
||||
normalizeParallelClientModel,
|
||||
normalizeParallelObjective,
|
||||
normalizeParallelSearchQueries,
|
||||
normalizeParallelSessionId,
|
||||
PARALLEL_FREE_SESSION_ID_MAX_LENGTH,
|
||||
resolveParallelSearchCount,
|
||||
stripParallelGeneratedSessionId,
|
||||
} from "./parallel-search-normalize.js";
|
||||
|
||||
export async function executeParallelFreeWebSearchProviderTool(
|
||||
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchConfig = mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"parallel-free",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "parallel-free"),
|
||||
) as SearchConfigRecord | undefined;
|
||||
|
||||
// Mirror the paid provider's generic `query` fallback (the operator CLI passes
|
||||
// `{ query, count }`); agent callers supply the native objective/search_queries.
|
||||
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);
|
||||
const count = resolveParallelSearchCount(requestedCount ?? DEFAULT_SEARCH_COUNT);
|
||||
const sessionId = normalizeParallelSessionId(
|
||||
readStringParam(args, "session_id"),
|
||||
PARALLEL_FREE_SESSION_ID_MAX_LENGTH,
|
||||
);
|
||||
const clientModel = normalizeParallelClientModel(readStringParam(args, "client_model"));
|
||||
const cacheKey = buildParallelCacheKey({
|
||||
endpoint: PARALLEL_MCP_SEARCH_URL,
|
||||
objective,
|
||||
searchQueries,
|
||||
count,
|
||||
sessionId,
|
||||
clientModel,
|
||||
});
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const response = await runParallelMcpSearch({
|
||||
objective,
|
||||
searchQueries,
|
||||
maxResults: count,
|
||||
sessionId,
|
||||
modelName: clientModel,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
signal,
|
||||
});
|
||||
const results = mapParallelResults(response);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
...(objective ? { objective } : {}),
|
||||
searchQueries,
|
||||
provider: "parallel-free",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "parallel-free",
|
||||
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;
|
||||
}
|
||||
|
||||
const cachePayload = sessionId ? payload : stripParallelGeneratedSessionId(payload);
|
||||
writeCachedSearchPayload(cacheKey, cachePayload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
const PARALLEL_FREE_ONBOARDING_SCOPES: Array<"text-inference"> = ["text-inference"];
|
||||
|
||||
export function createParallelFreeWebSearchProviderBase() {
|
||||
return {
|
||||
id: "parallel-free",
|
||||
label: "Parallel Search (Free)",
|
||||
hint: "Free web search via Parallel's hosted Search MCP — no API key required",
|
||||
onboardingScopes: [...PARALLEL_FREE_ONBOARDING_SCOPES],
|
||||
// Keyless: always uses Parallel's free hosted Search MCP. Keep it
|
||||
// selectable, but never auto-detected; users must opt in before search
|
||||
// traffic is sent to Parallel's hosted free tier. The paid `parallel`
|
||||
// provider (v1 REST, requires a key) is a separate entry.
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
placeholder: "(no key needed)",
|
||||
signupUrl: "https://parallel.ai",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/parallel-search",
|
||||
credentialPath: "",
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: "",
|
||||
searchCredential: { type: "scoped", scopeId: "parallel-free" },
|
||||
// Both Parallel providers live in the `parallel` plugin.
|
||||
selectionPluginId: "parallel",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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 MCP response.");
|
||||
}
|
||||
return await run(response);
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
withTrustedWebSearchEndpoint: vi.fn(runEndpoint),
|
||||
};
|
||||
});
|
||||
|
||||
import { createParallelFreeWebSearchProvider } from "./parallel-free-web-search-provider.js";
|
||||
|
||||
function jsonResponse(body: unknown, headers?: Record<string, string>): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function pushHandshake(toolPayload: unknown): void {
|
||||
endpointMockState.responses.push(
|
||||
jsonResponse(
|
||||
{ jsonrpc: "2.0", id: "i", result: { protocolVersion: "2025-06-18" } },
|
||||
{
|
||||
"mcp-session-id": "sess-1",
|
||||
},
|
||||
),
|
||||
jsonResponse({ jsonrpc: "2.0" }),
|
||||
jsonResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "c",
|
||||
result: { content: [{ type: "text", text: JSON.stringify(toolPayload) }] },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("parallel-free web search provider", () => {
|
||||
beforeEach(() => {
|
||||
endpointMockState.calls = [];
|
||||
endpointMockState.responses = [];
|
||||
});
|
||||
|
||||
it("exposes keyless metadata without claiming auto-detect fallback", () => {
|
||||
const provider = createParallelFreeWebSearchProvider();
|
||||
expect(provider.id).toBe("parallel-free");
|
||||
expect(provider.label).toBe("Parallel Search (Free)");
|
||||
expect(provider.requiresCredential).toBe(false);
|
||||
expect(provider.envVars).toEqual([]);
|
||||
expect(provider.autoDetectOrder).toBeUndefined();
|
||||
});
|
||||
|
||||
it("advertises the free MCP's tighter 100-char session_id cap in its tool schema", () => {
|
||||
const provider = createParallelFreeWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const sessionIdParam = (
|
||||
tool.parameters as { properties: Record<string, { maxLength?: number }> }
|
||||
).properties.session_id;
|
||||
expect(sessionIdParam.maxLength).toBe(100);
|
||||
});
|
||||
|
||||
it("searches via the free MCP and brands the result, with no API key", async () => {
|
||||
// No PARALLEL_API_KEY needed — the free path ignores keys entirely.
|
||||
vi.stubEnv("PARALLEL_API_KEY", "par-should-be-ignored"); // pragma: allowlist secret
|
||||
pushHandshake({
|
||||
search_id: "s1",
|
||||
results: [
|
||||
{
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
publish_date: "2024-01-01",
|
||||
excerpts: ["hi"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const provider = createParallelFreeWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const result = await tool.execute({
|
||||
objective: "find examples",
|
||||
search_queries: ["example"],
|
||||
});
|
||||
|
||||
// Three MCP calls (initialize -> notifications -> tools/call) to the free MCP.
|
||||
expect(endpointMockState.calls).toHaveLength(3);
|
||||
expect(endpointMockState.calls[0].url).toBe("https://search.parallel.ai/mcp");
|
||||
// No bearer token on the anonymous free path.
|
||||
expect(
|
||||
(endpointMockState.calls[0].init.headers as Record<string, string>).Authorization,
|
||||
).toBeUndefined();
|
||||
expect(result).toMatchObject({ provider: "parallel-free" });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect((result.results as unknown[]).length).toBe(1);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("drops an over-limit caller session id and mints one within the free MCP's 100-char cap", async () => {
|
||||
pushHandshake({ search_id: "s1", results: [] });
|
||||
const provider = createParallelFreeWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
await tool.execute({
|
||||
objective: "session cap check",
|
||||
search_queries: ["session cap"],
|
||||
session_id: "x".repeat(150),
|
||||
});
|
||||
|
||||
const toolsCallArgs = (
|
||||
JSON.parse(endpointMockState.calls[2].init.body as string).params as Record<string, unknown>
|
||||
).arguments as Record<string, unknown>;
|
||||
const sentSessionId = toolsCallArgs.session_id as string;
|
||||
// The 150-char caller id is out-of-contract for the free MCP; it is dropped
|
||||
// and replaced by a generated id that stays within the advertised 100-char cap.
|
||||
expect(sentSessionId).not.toBe("x".repeat(150));
|
||||
expect(sentSessionId.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("returns a structured error when search_queries is missing", async () => {
|
||||
const provider = createParallelFreeWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const result = await tool.execute({ objective: "x" });
|
||||
expect(result.error).toBe("invalid_search_queries");
|
||||
expect(endpointMockState.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
40
extensions/parallel/src/parallel-free-web-search-provider.ts
Normal file
40
extensions/parallel/src/parallel-free-web-search-provider.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createParallelFreeWebSearchProviderBase } from "./parallel-free-web-search-provider.shared.js";
|
||||
import { PARALLEL_FREE_SESSION_ID_MAX_LENGTH } from "./parallel-search-normalize.js";
|
||||
// Reuse the paid provider's tool schema — both transports accept the same
|
||||
// objective + search_queries shape — but the free Search MCP caps session_id at
|
||||
// 100 chars (its `tools/list` schema), tighter than the paid v1 REST limit, so
|
||||
// the free model-facing schema advertises that tighter bound.
|
||||
import { ParallelSearchSchema } from "./parallel-web-search-provider.js";
|
||||
|
||||
const ParallelFreeSearchSchema = {
|
||||
...ParallelSearchSchema,
|
||||
properties: {
|
||||
...ParallelSearchSchema.properties,
|
||||
session_id: {
|
||||
...ParallelSearchSchema.properties.session_id,
|
||||
maxLength: PARALLEL_FREE_SESSION_ID_MAX_LENGTH,
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
const loadParallelFreeWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./parallel-free-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
export function createParallelFreeWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createParallelFreeWebSearchProviderBase(),
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Parallel's free Search MCP (no API key). 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: ParallelFreeSearchSchema,
|
||||
execute: async (args, context) => {
|
||||
const { executeParallelFreeWebSearchProviderTool } =
|
||||
await loadParallelFreeWebSearchRuntime();
|
||||
return await executeParallelFreeWebSearchProviderTool(ctx, args, context?.signal);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
338
extensions/parallel/src/parallel-mcp-search.runtime.test.ts
Normal file
338
extensions/parallel/src/parallel-mcp-search.runtime.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
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 MCP response.");
|
||||
}
|
||||
return await run(response);
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
withTrustedWebSearchEndpoint: vi.fn(runEndpoint),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
extractMcpToolPayload,
|
||||
iterMcpMessages,
|
||||
runParallelMcpSearch,
|
||||
selectMcpEnvelope,
|
||||
} from "./parallel-mcp-search.runtime.js";
|
||||
|
||||
function jsonResponse(body: unknown, headers?: Record<string, string>): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(call: EndpointCall): Record<string, unknown> {
|
||||
if (typeof call.init.body !== "string") {
|
||||
throw new Error("Expected a JSON string body.");
|
||||
}
|
||||
return JSON.parse(call.init.body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function headerOf(call: EndpointCall, name: string): string | undefined {
|
||||
return (call.init.headers as Record<string, string>)[name];
|
||||
}
|
||||
|
||||
describe("iterMcpMessages", () => {
|
||||
it("parses a single JSON object body", () => {
|
||||
expect(iterMcpMessages('{"id":"a","result":{}}')).toEqual([{ id: "a", result: {} }]);
|
||||
});
|
||||
|
||||
it("flattens a JSON array batch", () => {
|
||||
expect(iterMcpMessages('[{"id":"a"},{"id":"b"}]')).toEqual([{ id: "a" }, { id: "b" }]);
|
||||
});
|
||||
|
||||
it("parses SSE events with concatenated data lines", () => {
|
||||
const sse = [
|
||||
"event: message",
|
||||
'data: {"id":"a",',
|
||||
'data: "result":{}}',
|
||||
"",
|
||||
'data: {"id":"b"}',
|
||||
"",
|
||||
].join("\n");
|
||||
expect(iterMcpMessages(sse)).toEqual([{ id: "a", result: {} }, { id: "b" }]);
|
||||
});
|
||||
|
||||
it("skips unparseable chunks and empty bodies", () => {
|
||||
expect(iterMcpMessages("")).toEqual([]);
|
||||
expect(iterMcpMessages("not json")).toEqual([]);
|
||||
expect(iterMcpMessages("data: {bad json}\n\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectMcpEnvelope", () => {
|
||||
it("returns the message whose id matches, skipping notifications", () => {
|
||||
const body = [
|
||||
'{"jsonrpc":"2.0","method":"notifications/progress"}',
|
||||
'{"jsonrpc":"2.0","id":"other","result":{"n":1}}',
|
||||
'{"jsonrpc":"2.0","id":"want","result":{"n":2}}',
|
||||
]
|
||||
.map((line) => `data: ${line}`)
|
||||
.join("\n\n");
|
||||
expect(selectMcpEnvelope(body, "want")).toMatchObject({ id: "want", result: { n: 2 } });
|
||||
});
|
||||
|
||||
it("falls back to the last result-bearing message when no id matches", () => {
|
||||
const body = '{"id":"x","result":{"first":true}}\n';
|
||||
expect(selectMcpEnvelope(body, "missing")).toMatchObject({ result: { first: true } });
|
||||
});
|
||||
|
||||
it("returns {} when there is no result or error message", () => {
|
||||
expect(selectMcpEnvelope('{"method":"notifications/initialized"}', "any")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMcpToolPayload", () => {
|
||||
it("prefers structuredContent", () => {
|
||||
expect(extractMcpToolPayload({ result: { structuredContent: { results: [1] } } })).toEqual({
|
||||
results: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the first JSON-parseable text block", () => {
|
||||
expect(
|
||||
extractMcpToolPayload({
|
||||
result: {
|
||||
content: [
|
||||
{ type: "text", text: "not json" },
|
||||
{ type: "text", text: '{"ok":true}' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("throws on a JSON-RPC error", () => {
|
||||
expect(() => extractMcpToolPayload({ error: { code: -1, message: "boom" } })).toThrow(
|
||||
/Parallel MCP error/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on a tool-level isError", () => {
|
||||
expect(() =>
|
||||
extractMcpToolPayload({ result: { isError: true, content: [{ type: "text", text: "{}" }] } }),
|
||||
).toThrow(/Parallel MCP tool error/);
|
||||
});
|
||||
|
||||
it("throws when there is no parseable content", () => {
|
||||
expect(() => extractMcpToolPayload({ result: { content: [] } })).toThrow(
|
||||
/no parseable content/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runParallelMcpSearch", () => {
|
||||
beforeEach(() => {
|
||||
endpointMockState.calls = [];
|
||||
endpointMockState.responses = [];
|
||||
});
|
||||
|
||||
it("runs the 3-step handshake and maps results into the REST-compatible shape", async () => {
|
||||
endpointMockState.responses.push(
|
||||
jsonResponse(
|
||||
{ jsonrpc: "2.0", id: "ignored", result: { protocolVersion: "2025-06-18" } },
|
||||
{ "mcp-session-id": "server-session-1" },
|
||||
),
|
||||
jsonResponse({ jsonrpc: "2.0" }), // notifications/initialized ack
|
||||
jsonResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "ignored",
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
search_id: "search_abc",
|
||||
results: [
|
||||
{
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
publish_date: "2024-01-01",
|
||||
excerpts: ["hi"],
|
||||
},
|
||||
{ url: "https://second.com", title: "Second", excerpts: ["yo"] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await runParallelMcpSearch({
|
||||
objective: "find examples",
|
||||
searchQueries: ["example query"],
|
||||
maxResults: 1,
|
||||
modelName: "claude-opus-4-8",
|
||||
});
|
||||
|
||||
// 3 HTTP calls: initialize, notifications/initialized, tools/call.
|
||||
expect(endpointMockState.calls.map((c) => readBody(c).method)).toEqual([
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"tools/call",
|
||||
]);
|
||||
// Server session id + a negotiated protocol version are echoed post-init.
|
||||
expect(headerOf(endpointMockState.calls[1], "Mcp-Session-Id")).toBe("server-session-1");
|
||||
expect(headerOf(endpointMockState.calls[2], "Mcp-Session-Id")).toBe("server-session-1");
|
||||
expect(headerOf(endpointMockState.calls[2], "MCP-Protocol-Version")).toBe("2025-06-18");
|
||||
// No bearer token on the anonymous free path.
|
||||
expect(headerOf(endpointMockState.calls[0], "Authorization")).toBeUndefined();
|
||||
// Every call identifies OpenClaw at the HTTP layer (not just node).
|
||||
for (const call of endpointMockState.calls) {
|
||||
expect(headerOf(call, "User-Agent")).toMatch(/^openclaw-parallel\//);
|
||||
}
|
||||
// tools/call carries the documented web_search args.
|
||||
const callArgs = (readBody(endpointMockState.calls[2]).params as Record<string, unknown>)
|
||||
.arguments as Record<string, unknown>;
|
||||
expect(callArgs).toMatchObject({
|
||||
objective: "find examples",
|
||||
search_queries: ["example query"],
|
||||
model_name: "claude-opus-4-8",
|
||||
});
|
||||
expect(typeof callArgs.session_id).toBe("string");
|
||||
|
||||
// maxResults applied client-side; mapped to the REST-compatible response.
|
||||
expect(response.search_id).toBe("search_abc");
|
||||
expect(response.results).toHaveLength(1);
|
||||
expect(response.results[0]).toMatchObject({ url: "https://example.com", title: "Example" });
|
||||
});
|
||||
|
||||
it("uses the search queries as the objective when none was supplied", async () => {
|
||||
endpointMockState.responses.push(
|
||||
jsonResponse({ jsonrpc: "2.0", id: "i", result: {} }, { "mcp-session-id": "s" }),
|
||||
jsonResponse({ jsonrpc: "2.0" }),
|
||||
jsonResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "c",
|
||||
result: { content: [{ type: "text", text: JSON.stringify({ results: [] }) }] },
|
||||
}),
|
||||
);
|
||||
|
||||
await runParallelMcpSearch({ searchQueries: ["alpha", "beta"], maxResults: 5 });
|
||||
|
||||
const callArgs = (readBody(endpointMockState.calls[2]).params as Record<string, unknown>)
|
||||
.arguments as Record<string, unknown>;
|
||||
expect(callArgs.objective).toBe("alpha beta");
|
||||
});
|
||||
|
||||
it("forwards a caller-supplied session id verbatim (no re-minting)", async () => {
|
||||
endpointMockState.responses.push(
|
||||
jsonResponse({ jsonrpc: "2.0", id: "i", result: {} }, { "mcp-session-id": "s" }),
|
||||
jsonResponse({ jsonrpc: "2.0" }),
|
||||
jsonResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "c",
|
||||
result: { content: [{ type: "text", text: JSON.stringify({ results: [] }) }] },
|
||||
}),
|
||||
);
|
||||
// The MCP client is a dumb transport: an already-normalized caller id (the
|
||||
// provider runtime caps it at the free MCP's 100-char limit) is forwarded as
|
||||
// sent, so the MCP session, cache key, and reported id stay in agreement.
|
||||
const callerSessionId = `sess-${"a".repeat(40)}`;
|
||||
const response = await runParallelMcpSearch({
|
||||
searchQueries: ["x"],
|
||||
maxResults: 5,
|
||||
sessionId: callerSessionId,
|
||||
});
|
||||
const callArgs = (readBody(endpointMockState.calls[2]).params as Record<string, unknown>)
|
||||
.arguments as Record<string, unknown>;
|
||||
expect(callArgs.session_id).toBe(callerSessionId);
|
||||
expect(response.session_id).toBe(callerSessionId);
|
||||
});
|
||||
|
||||
it("throws when initialize fails", async () => {
|
||||
endpointMockState.responses.push(new Response("nope", { status: 500 }));
|
||||
await expect(runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 })).rejects.toThrow(
|
||||
/initialize failed \(500\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds initialize error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"parallel mcp unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
endpointMockState.responses.push(tracked.response);
|
||||
|
||||
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(/initialize failed \(503\): parallel mcp unavailable/);
|
||||
expect((error as Error).message).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds successful MCP bodies without using response.text()", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
endpointMockState.responses.push(streamed.response);
|
||||
|
||||
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
"Parallel MCP: text response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
355
extensions/parallel/src/parallel-mcp-search.runtime.ts
Normal file
355
extensions/parallel/src/parallel-mcp-search.runtime.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
// Free hosted Search MCP. This keyless transport is used only after the user
|
||||
// explicitly selects the `parallel-free` web_search provider. Docs:
|
||||
// https://docs.parallel.ai/integrations/mcp/search-mcp
|
||||
export const PARALLEL_MCP_SEARCH_URL = "https://search.parallel.ai/mcp";
|
||||
// Initial protocol version we advertise on `initialize`; we then echo whatever
|
||||
// the server negotiates back on every follow-up request.
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
const MCP_TIMEOUT_SECONDS = 30;
|
||||
const PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const PLUGIN_VERSION = readPluginPackageVersion({ require });
|
||||
// Identify free-tier traffic at the HTTP layer (mirrors the paid REST path);
|
||||
// without this, undici sends a generic `node` UA and OpenClaw usage is only
|
||||
// visible via the JSON-RPC `clientInfo` payload.
|
||||
const USER_AGENT = `openclaw-parallel/${PLUGIN_VERSION} (${process.platform})`;
|
||||
|
||||
type JsonRpcMessage = Record<string, unknown>;
|
||||
|
||||
type McpToolPayload = Record<string, unknown>;
|
||||
|
||||
/** ParallelSearchResponse-compatible shape consumed by the runtime normalizer. */
|
||||
export type ParallelMcpSearchResponse = {
|
||||
search_id?: unknown;
|
||||
session_id?: unknown;
|
||||
results: unknown[];
|
||||
warnings?: unknown;
|
||||
usage?: unknown;
|
||||
};
|
||||
|
||||
function mcpHeaders(params: {
|
||||
sessionId?: string;
|
||||
protocolVersion?: string;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
// The Search MCP may answer either as a single JSON object or as an SSE
|
||||
// stream; advertise both so the server can pick.
|
||||
Accept: "application/json, text/event-stream",
|
||||
};
|
||||
// After `initialize` the Streamable-HTTP spec expects the negotiated session
|
||||
// id and protocol version echoed on every follow-up request.
|
||||
if (params.sessionId) {
|
||||
headers["Mcp-Session-Id"] = params.sessionId;
|
||||
}
|
||||
if (params.protocolVersion) {
|
||||
headers["MCP-Protocol-Version"] = params.protocolVersion;
|
||||
}
|
||||
// No Authorization header: the free tier is anonymous, and sending an
|
||||
// empty/garbage bearer would flip the server to a 401 instead of serving it.
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield JSON-RPC message objects from a plain-JSON or SSE response body.
|
||||
*
|
||||
* Handles `application/json` (a single object) and `text/event-stream` (SSE:
|
||||
* events separated by blank lines; an event's one-or-more `data:` lines
|
||||
* concatenate into a single JSON payload). Streamable HTTP also allows batching
|
||||
* responses into a JSON array, so arrays are flattened. Unparseable chunks and
|
||||
* non-`data` SSE fields (`event:`/`id:`/comments) are skipped.
|
||||
*/
|
||||
export function iterMcpMessages(text: string): JsonRpcMessage[] {
|
||||
const out: JsonRpcMessage[] = [];
|
||||
const emit = (payload: unknown): void => {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const entry of payload) {
|
||||
if (isRecord(entry)) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
} else if (isRecord(payload)) {
|
||||
out.push(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const body = (text ?? "").trim();
|
||||
if (!body) {
|
||||
return out;
|
||||
}
|
||||
if (body.startsWith("{") || body.startsWith("[")) {
|
||||
try {
|
||||
emit(JSON.parse(body));
|
||||
} catch {
|
||||
// Non-JSON body: nothing to emit.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let dataLines: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (dataLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emit(JSON.parse(dataLines.join("\n")));
|
||||
} catch {
|
||||
// Skip an unparseable SSE event rather than failing the whole stream.
|
||||
}
|
||||
dataLines = [];
|
||||
};
|
||||
|
||||
for (const raw of body.split("\n")) {
|
||||
const line = raw.replace(/\r$/, "");
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).replace(/^ /, ""));
|
||||
} else if (line.trim() === "") {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the JSON-RPC response for `requestId` from an MCP response body.
|
||||
*
|
||||
* Streamable-HTTP servers may emit progress/log notifications before the final
|
||||
* result, so scan the whole stream and return the result/error message whose
|
||||
* `id` matches. Falls back to the last result/error-bearing message if no id
|
||||
* matches; `{}` if none is present.
|
||||
*/
|
||||
export function selectMcpEnvelope(text: string, requestId: string): JsonRpcMessage {
|
||||
let fallback: JsonRpcMessage = {};
|
||||
for (const msg of iterMcpMessages(text)) {
|
||||
if (!("result" in msg || "error" in msg)) {
|
||||
continue;
|
||||
}
|
||||
if (msg.id === requestId) {
|
||||
return msg;
|
||||
}
|
||||
fallback = msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the tool result payload from a `tools/call` envelope.
|
||||
*
|
||||
* Prefers `structuredContent` (authoritative machine-readable form); otherwise
|
||||
* scans text blocks for the first JSON-parseable one. Throws on a JSON-RPC
|
||||
* error or a tool-level `isError`.
|
||||
*/
|
||||
export function extractMcpToolPayload(envelope: JsonRpcMessage): McpToolPayload {
|
||||
if ("error" in envelope) {
|
||||
throw new Error(`Parallel MCP error: ${JSON.stringify(envelope.error).slice(0, 500)}`);
|
||||
}
|
||||
const result = isRecord(envelope.result) ? envelope.result : {};
|
||||
if (result.isError) {
|
||||
throw new Error(`Parallel MCP tool error: ${JSON.stringify(result).slice(0, 500)}`);
|
||||
}
|
||||
if (isRecord(result.structuredContent)) {
|
||||
return result.structuredContent;
|
||||
}
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
for (const block of content) {
|
||||
if (isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(block.text);
|
||||
if (isRecord(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Try the next text block.
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Parallel MCP returned no parseable content: ${JSON.stringify(result).slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
type McpHttpResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
text: string;
|
||||
sessionIdHeader: string | null;
|
||||
};
|
||||
|
||||
async function postMcp(params: {
|
||||
body: JsonRpcMessage;
|
||||
sessionId?: string;
|
||||
protocolVersion?: string;
|
||||
timeoutSeconds: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<McpHttpResult> {
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: PARALLEL_MCP_SEARCH_URL,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
signal: params.signal,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: mcpHeaders({
|
||||
sessionId: params.sessionId,
|
||||
protocolVersion: params.protocolVersion,
|
||||
}),
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
},
|
||||
// Read the body inside the callback: the trusted-endpoint wrapper ties the
|
||||
// request's abort/timeout lifecycle to this scope (same pattern as the REST
|
||||
// path), so the Response must be consumed here, not returned and read later.
|
||||
async (response) => ({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
text: response.ok
|
||||
? await readProviderTextResponse(response, "Parallel MCP")
|
||||
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
|
||||
sessionIdHeader: response.headers.get("mcp-session-id"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the MCP handshake then a single `tools/call`, returning the tool payload.
|
||||
*
|
||||
* initialize -> (capture `Mcp-Session-Id` header + negotiated protocolVersion)
|
||||
* -> notifications/initialized -> tools/call. Anonymous (no bearer token).
|
||||
*/
|
||||
async function mcpCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutSeconds: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<McpToolPayload> {
|
||||
// 1. initialize — capture the server-assigned session id + negotiated version.
|
||||
const initId = randomUUID();
|
||||
const init = await postMcp({
|
||||
timeoutSeconds,
|
||||
signal,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: initId,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "openclaw-parallel", version: PLUGIN_VERSION },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!init.ok) {
|
||||
throw new Error(
|
||||
`Parallel MCP initialize failed (${init.status}): ${init.text || init.statusText}`,
|
||||
);
|
||||
}
|
||||
// Only echo a server-assigned session id. Stateless Streamable HTTP servers
|
||||
// omit Mcp-Session-Id; inventing one can make such servers reject follow-ups.
|
||||
const sessionId = init.sessionIdHeader ?? undefined;
|
||||
const initEnvelope = selectMcpEnvelope(init.text, initId);
|
||||
const negotiatedVersion =
|
||||
(isRecord(initEnvelope.result) && typeof initEnvelope.result.protocolVersion === "string"
|
||||
? initEnvelope.result.protocolVersion
|
||||
: undefined) ?? MCP_PROTOCOL_VERSION;
|
||||
|
||||
// 2. notifications/initialized — required handshake ack (no response body).
|
||||
await postMcp({
|
||||
body: { jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
sessionId,
|
||||
protocolVersion: negotiatedVersion,
|
||||
timeoutSeconds,
|
||||
signal,
|
||||
});
|
||||
|
||||
// 3. tools/call.
|
||||
const callId = randomUUID();
|
||||
const call = await postMcp({
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: callId,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
},
|
||||
sessionId,
|
||||
protocolVersion: negotiatedVersion,
|
||||
timeoutSeconds,
|
||||
signal,
|
||||
});
|
||||
if (!call.ok) {
|
||||
throw new Error(
|
||||
`Parallel MCP tools/call failed (${call.status}): ${call.text || call.statusText}`,
|
||||
);
|
||||
}
|
||||
return extractMcpToolPayload(selectMcpEnvelope(call.text, callId));
|
||||
}
|
||||
|
||||
function normalizeMcpSessionId(value: string | undefined): string {
|
||||
// Use the caller-supplied id verbatim — the runtime already applies the
|
||||
// shared session-id contract before building the cache key, so reusing it
|
||||
// here keeps the MCP session, cache key, and reported sessionId in agreement
|
||||
// (re-minting a valid id would silently break session grouping). The Search
|
||||
// MCP requires a session_id, so mint a per-call uuid only when none was given.
|
||||
return value?.trim() || randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a `web_search` tool call against the free hosted Search MCP and return a
|
||||
* `ParallelSearchResponse`-compatible object so the runtime's existing result
|
||||
* normalization (`normalizeParallelResults`) is reused verbatim.
|
||||
*/
|
||||
export async function runParallelMcpSearch(params: {
|
||||
objective?: string;
|
||||
searchQueries: readonly string[];
|
||||
maxResults: number;
|
||||
sessionId?: string;
|
||||
modelName?: string;
|
||||
timeoutSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ParallelMcpSearchResponse> {
|
||||
const sessionId = normalizeMcpSessionId(params.sessionId);
|
||||
const args: Record<string, unknown> = {
|
||||
// MCP requires a non-empty objective (REST treats it as optional); when the
|
||||
// caller only supplied keyword queries, use them as the objective rather
|
||||
// than failing the call.
|
||||
objective: params.objective ?? params.searchQueries.join(" "),
|
||||
search_queries: [...params.searchQueries],
|
||||
session_id: sessionId,
|
||||
};
|
||||
if (params.modelName) {
|
||||
args.model_name = params.modelName;
|
||||
}
|
||||
|
||||
const payload = await mcpCall(
|
||||
"web_search",
|
||||
args,
|
||||
params.timeoutSeconds ?? MCP_TIMEOUT_SECONDS,
|
||||
params.signal,
|
||||
);
|
||||
const allResults = Array.isArray(payload.results) ? payload.results : [];
|
||||
// The MCP serves a fixed result count, so apply the caller's count client-side
|
||||
// to match the REST path's max_results behavior.
|
||||
const results = allResults.slice(0, Math.max(params.maxResults, 1));
|
||||
|
||||
return {
|
||||
search_id: typeof payload.search_id === "string" ? payload.search_id : undefined,
|
||||
session_id: sessionId,
|
||||
results,
|
||||
warnings: payload.warnings,
|
||||
usage: payload.usage,
|
||||
};
|
||||
}
|
||||
197
extensions/parallel/src/parallel-search-normalize.ts
Normal file
197
extensions/parallel/src/parallel-search-normalize.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Transport-agnostic Parallel search normalization shared by the paid REST
|
||||
// provider (`parallel`) and the free Search MCP provider (`parallel-free`).
|
||||
// Both transports return the same v1 result shape, so query/result handling
|
||||
// lives here instead of being copied into each runtime.
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
resolveSiteName,
|
||||
wrapWebContent,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
// Internal-only bounds (the model-facing tool schema declares its own copies).
|
||||
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;
|
||||
// Paid v1 REST accepts session ids up to 1000 chars, but the free Search MCP
|
||||
// `tools/list` schema caps session_id at 100. Each runtime passes its own limit
|
||||
// (and advertises it in the tool schema) so callers never send an out-of-contract id.
|
||||
export const PARALLEL_SESSION_ID_MAX_LENGTH = 1000;
|
||||
export const PARALLEL_FREE_SESSION_ID_MAX_LENGTH = 100;
|
||||
const PARALLEL_CLIENT_MODEL_MAX_LENGTH = 100;
|
||||
|
||||
export type ParallelSearchResult = {
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
publish_date?: unknown;
|
||||
excerpts?: unknown;
|
||||
};
|
||||
|
||||
export type ParallelSearchResponse = {
|
||||
search_id?: unknown;
|
||||
session_id?: unknown;
|
||||
results?: unknown;
|
||||
warnings?: unknown;
|
||||
usage?: unknown;
|
||||
};
|
||||
|
||||
export function resolveParallelSearchCount(value: number): number {
|
||||
return Math.max(1, Math.min(PARALLEL_MAX_SEARCH_COUNT, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function normalizeParallelSessionId(
|
||||
value: string | undefined,
|
||||
maxLength: number,
|
||||
): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
return trimmed && trimmed.length <= maxLength ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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.
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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",
|
||||
};
|
||||
}
|
||||
|
||||
export 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)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps a Parallel v1 response into wrapped `web_search` result entries. */
|
||||
export function mapParallelResults(response: ParallelSearchResponse): Record<string, unknown>[] {
|
||||
return 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 } : {},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a Parallel-generated `sessionId` before caching. Identical queries from
|
||||
* unrelated tasks would otherwise share that id; caller-supplied session ids are
|
||||
* part of the cache key, so a cache hit only ever returns the matching id.
|
||||
*/
|
||||
export 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 function buildParallelCacheKey(params: {
|
||||
endpoint: string;
|
||||
objective?: string;
|
||||
searchQueries: readonly string[];
|
||||
count: number;
|
||||
sessionId?: string;
|
||||
clientModel?: string;
|
||||
}): string {
|
||||
return buildSearchCacheKey([
|
||||
"parallel",
|
||||
// The transport endpoint (REST URL or the free MCP URL) partitions paid-REST
|
||||
// vs free-MCP and REST endpoint overrides so transports never share cached
|
||||
// payloads.
|
||||
params.endpoint,
|
||||
params.objective,
|
||||
// Join with a NUL delimiter (can't appear in normalized queries) so distinct
|
||||
// arrays like ["ab","c"] and ["a","bc"] don't collide on the same cache key.
|
||||
params.searchQueries.join("\u0000"),
|
||||
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,
|
||||
]);
|
||||
}
|
||||
296
extensions/parallel/src/parallel-web-search-provider.runtime.ts
Normal file
296
extensions/parallel/src/parallel-web-search-provider.runtime.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchTimeoutSeconds,
|
||||
type SearchConfigRecord,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
buildParallelCacheKey,
|
||||
invalidSearchQueriesPayload,
|
||||
mapParallelResults,
|
||||
normalizeParallelClientModel,
|
||||
normalizeParallelObjective,
|
||||
normalizeParallelResults,
|
||||
normalizeParallelSearchQueries,
|
||||
normalizeParallelSessionId,
|
||||
PARALLEL_SESSION_ID_MAX_LENGTH,
|
||||
type ParallelSearchResponse,
|
||||
resolveParallelSearchCount,
|
||||
stripParallelGeneratedSessionId,
|
||||
} from "./parallel-search-normalize.js";
|
||||
|
||||
const PARALLEL_BASE_URL = "https://api.parallel.ai";
|
||||
const PARALLEL_SEARCH_PATHNAME = "/v1/search";
|
||||
const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
// Parallel's /v1/search returns a bounded result set, but the body is external
|
||||
// (web-search upstream) and untrusted. Cap the successful JSON read so a
|
||||
// hostile or malfunctioning endpoint streaming an unbounded body cannot force
|
||||
// the runtime to buffer the whole payload before parsing. 16 MiB matches the
|
||||
// shared provider JSON cap (readProviderJsonResponse default).
|
||||
const PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 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 readResponseTextLimited(res, PARALLEL_ERROR_BODY_LIMIT_BYTES).catch(
|
||||
() => "",
|
||||
);
|
||||
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
return await readProviderJsonResponse<ParallelSearchResponse>(res, "Parallel API", {
|
||||
maxBytes: PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
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 Parallel's own default of 10.
|
||||
const count = resolveParallelSearchCount(requestedCount ?? DEFAULT_SEARCH_COUNT);
|
||||
const sessionId = normalizeParallelSessionId(
|
||||
readStringParam(args, "session_id"),
|
||||
PARALLEL_SESSION_ID_MAX_LENGTH,
|
||||
);
|
||||
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 = mapParallelResults(response);
|
||||
|
||||
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.
|
||||
// Caller-supplied session ids are already part of the cache key.
|
||||
const cachePayload = sessionId ? payload : stripParallelGeneratedSessionId(payload);
|
||||
writeCachedSearchPayload(cacheKey, cachePayload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
buildParallelCacheKey,
|
||||
invalidSearchQueriesPayload,
|
||||
missingParallelKeyPayload,
|
||||
normalizeParallelClientModel,
|
||||
normalizeParallelObjective,
|
||||
normalizeParallelResults,
|
||||
normalizeParallelSearchQueries,
|
||||
normalizeParallelSessionId,
|
||||
resolveParallelApiKey,
|
||||
resolveParallelConfig,
|
||||
resolveParallelSearchCount,
|
||||
resolveParallelSearchEndpoint,
|
||||
PARALLEL_ERROR_BODY_LIMIT_BYTES,
|
||||
PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
|
||||
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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
725
extensions/parallel/src/parallel-web-search-provider.test.ts
Normal file
725
extensions/parallel/src/parallel-web-search-provider.test.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
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 values past the given limit", () => {
|
||||
expect(testing.normalizeParallelSessionId("session-abc", 1000)).toBe("session-abc");
|
||||
expect(testing.normalizeParallelSessionId(" ", 1000)).toBeUndefined();
|
||||
expect(testing.normalizeParallelSessionId(undefined, 1000)).toBeUndefined();
|
||||
expect(testing.normalizeParallelSessionId("x".repeat(1001), 1000)).toBeUndefined();
|
||||
// Free Search MCP caps session_id at 100, so the tighter limit drops longer ids.
|
||||
expect(testing.normalizeParallelSessionId("x".repeat(101), 100)).toBeUndefined();
|
||||
expect(testing.normalizeParallelSessionId("x".repeat(100), 100)).toBe("x".repeat(100));
|
||||
});
|
||||
|
||||
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("bounds Parallel API error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"parallel upstream unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
endpointMockState.responses.push(tracked.response);
|
||||
const provider = createParallelWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { parallel: { apiKey: "par-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const error = await tool
|
||||
.execute({
|
||||
objective: `parallel-error-body-${Date.now()}`,
|
||||
search_queries: ["openclaw"],
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(
|
||||
/Parallel API error \(503\): parallel upstream unavailable/,
|
||||
);
|
||||
expect((error as Error).message).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds successful Parallel JSON bodies instead of buffering the whole response", async () => {
|
||||
// 200-chunk x 1 MiB body (~200 MiB) caps at 16 MiB: the bounded reader must
|
||||
// stop pulling chunks and cancel the stream well before draining it, then
|
||||
// surface a bounded error rather than buffering the whole payload.
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 200,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "a",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
endpointMockState.responses.push(streamed.response);
|
||||
const provider = createParallelWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { parallel: { apiKey: "par-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const error = await tool
|
||||
.execute({
|
||||
objective: `parallel-success-body-${Date.now()}-${Math.random()}`,
|
||||
search_queries: ["openclaw"],
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(
|
||||
new RegExp(
|
||||
`Parallel API: JSON response exceeds ${testing.PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES} bytes`,
|
||||
),
|
||||
);
|
||||
// Stopped well before draining all 200 chunks, and cancelled the stream.
|
||||
expect(streamed.getReadCount()).toBeLessThan(200);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("parses a well-formed Parallel JSON body under the byte cap", async () => {
|
||||
endpointMockState.responses.push(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
search_id: "ok",
|
||||
session_id: "ok-session",
|
||||
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" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const result = (await tool.execute({
|
||||
objective: `parallel-success-ok-${Date.now()}-${Math.random()}`,
|
||||
search_queries: ["openclaw"],
|
||||
})) as { provider?: string; searchId?: string; count?: number };
|
||||
expect(result).toMatchObject({ provider: "parallel", searchId: "ok", count: 1 });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
71
extensions/parallel/src/parallel-web-search-provider.ts
Normal file
71
extensions/parallel/src/parallel-web-search-provider.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
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;
|
||||
|
||||
const loadParallelWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./parallel-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
// Mirrors Parallel's recommended search tool schema:
|
||||
// https://docs.parallel.ai/search/best-practices#search-tool-definition
|
||||
export 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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user