Add Parallel as web-search and web-fetch tools
This commit is contained in:
parent
fb14146033
commit
77c59d53c8
@ -1,17 +1,18 @@
|
|||||||
---
|
---
|
||||||
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)"
|
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Parallel)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to enable web_search or web_fetch
|
- You want to enable web_search or web_fetch
|
||||||
- You need Brave Search API key setup
|
- You need Brave Search API key setup
|
||||||
- You want to use Perplexity Sonar for web search
|
- You want to use Perplexity Sonar for web search
|
||||||
|
- You want to use Parallel for web search or content extraction
|
||||||
---
|
---
|
||||||
|
|
||||||
# Web tools
|
# Web tools
|
||||||
|
|
||||||
Clawdbot ships two lightweight web tools:
|
Clawdbot ships two lightweight web tools:
|
||||||
|
|
||||||
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter).
|
- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar (direct or via OpenRouter), or Parallel Search API.
|
||||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). Supports Readability, Firecrawl, and Parallel extract.
|
||||||
|
|
||||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||||
[Browser tool](/tools/browser).
|
[Browser tool](/tools/browser).
|
||||||
@ -32,6 +33,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
|||||||
|----------|------|------|---------|
|
|----------|------|------|---------|
|
||||||
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
||||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||||
|
| **Parallel** | Relevant excerpts, real-time, agentic mode | Requires Parallel API access | `PARALLEL_API_KEY` |
|
||||||
|
|
||||||
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
||||||
|
|
||||||
@ -42,7 +44,7 @@ Set the provider in config:
|
|||||||
tools: {
|
tools: {
|
||||||
web: {
|
web: {
|
||||||
search: {
|
search: {
|
||||||
provider: "brave" // or "perplexity"
|
provider: "brave" // or "perplexity" or "parallel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -138,6 +140,64 @@ If no base URL is set, Clawdbot chooses a default based on the API key source:
|
|||||||
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
|
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
|
||||||
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
|
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
|
||||||
|
|
||||||
|
## Using Parallel
|
||||||
|
|
||||||
|
Parallel provides web search and content extraction APIs optimized for agentic workflows.
|
||||||
|
Results include relevant excerpts from each page.
|
||||||
|
|
||||||
|
### Getting a Parallel API key
|
||||||
|
|
||||||
|
1) Sign up at https://parallel.ai/
|
||||||
|
2) Generate an API key in your account settings
|
||||||
|
|
||||||
|
### Setting up Parallel search
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
enabled: true,
|
||||||
|
provider: "parallel",
|
||||||
|
parallel: {
|
||||||
|
// API key (optional if PARALLEL_API_KEY is set)
|
||||||
|
apiKey: "your-parallel-api-key",
|
||||||
|
// Base URL (optional, defaults to https://api.parallel.ai)
|
||||||
|
baseUrl: "https://api.parallel.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment alternative:** set `PARALLEL_API_KEY` in the Gateway environment.
|
||||||
|
For a gateway install, put it in `~/.clawdbot/.env`.
|
||||||
|
|
||||||
|
### Using Parallel extract for web_fetch
|
||||||
|
|
||||||
|
Parallel extract can be used as a content extractor for `web_fetch`, providing
|
||||||
|
markdown content from web pages. Enable it alongside or instead of Readability/Firecrawl:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
fetch: {
|
||||||
|
parallel: {
|
||||||
|
enabled: true,
|
||||||
|
// API key (optional if PARALLEL_API_KEY is set)
|
||||||
|
apiKey: "your-parallel-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, Parallel extract is tried first for HTML content. If it fails,
|
||||||
|
Clawdbot falls back to Readability and then Firecrawl (if configured).
|
||||||
|
|
||||||
## web_search
|
## web_search
|
||||||
|
|
||||||
Search the web using your configured provider.
|
Search the web using your configured provider.
|
||||||
@ -148,6 +208,7 @@ Search the web using your configured provider.
|
|||||||
- API key for your chosen provider:
|
- API key for your chosen provider:
|
||||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||||
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||||
|
- **Parallel**: `PARALLEL_API_KEY` or `tools.web.search.parallel.apiKey`
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
|
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const DEFAULT_FETCH_MAX_REDIRECTS = 3;
|
|||||||
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
||||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||||
|
const DEFAULT_PARALLEL_BASE_URL = "https://api.parallel.ai";
|
||||||
const DEFAULT_FETCH_USER_AGENT =
|
const DEFAULT_FETCH_USER_AGENT =
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
@ -78,6 +79,32 @@ type FirecrawlFetchConfig =
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
type ParallelExtractConfig =
|
||||||
|
| {
|
||||||
|
enabled?: boolean;
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
type ParallelExtractResponse = {
|
||||||
|
extract_id: string;
|
||||||
|
results: Array<{
|
||||||
|
url: string;
|
||||||
|
title?: string | null;
|
||||||
|
full_content?: string | null;
|
||||||
|
excerpts?: string[] | null;
|
||||||
|
publish_date?: string | null;
|
||||||
|
}>;
|
||||||
|
errors: Array<{
|
||||||
|
url: string;
|
||||||
|
error_type: string;
|
||||||
|
http_status_code?: number | null;
|
||||||
|
content?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveFetchConfig(cfg?: ClawdbotConfig): WebFetchConfig {
|
function resolveFetchConfig(cfg?: ClawdbotConfig): WebFetchConfig {
|
||||||
const fetch = cfg?.tools?.web?.fetch;
|
const fetch = cfg?.tools?.web?.fetch;
|
||||||
if (!fetch || typeof fetch !== "object") return undefined;
|
if (!fetch || typeof fetch !== "object") return undefined;
|
||||||
@ -147,6 +174,38 @@ function resolveFirecrawlMaxAgeMsOrDefault(firecrawl?: FirecrawlFetchConfig): nu
|
|||||||
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
|
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveParallelExtractConfig(fetch?: WebFetchConfig): ParallelExtractConfig {
|
||||||
|
if (!fetch || typeof fetch !== "object") return undefined;
|
||||||
|
const parallel = "parallel" in fetch ? fetch.parallel : undefined;
|
||||||
|
if (!parallel || typeof parallel !== "object") return undefined;
|
||||||
|
return parallel as ParallelExtractConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParallelExtractApiKey(parallel?: ParallelExtractConfig): string | undefined {
|
||||||
|
const fromConfig =
|
||||||
|
parallel && "apiKey" in parallel && typeof parallel.apiKey === "string"
|
||||||
|
? parallel.apiKey.trim()
|
||||||
|
: "";
|
||||||
|
const fromEnv = (process.env.PARALLEL_API_KEY ?? "").trim();
|
||||||
|
return fromConfig || fromEnv || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParallelExtractEnabled(params: {
|
||||||
|
parallel?: ParallelExtractConfig;
|
||||||
|
apiKey?: string;
|
||||||
|
}): boolean {
|
||||||
|
if (typeof params.parallel?.enabled === "boolean") return params.parallel.enabled;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParallelExtractBaseUrl(parallel?: ParallelExtractConfig): string {
|
||||||
|
const raw =
|
||||||
|
parallel && "baseUrl" in parallel && typeof parallel.baseUrl === "string"
|
||||||
|
? parallel.baseUrl.trim()
|
||||||
|
: "";
|
||||||
|
return raw || DEFAULT_PARALLEL_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMaxChars(value: unknown, fallback: number): number {
|
function resolveMaxChars(value: unknown, fallback: number): number {
|
||||||
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
return Math.max(100, Math.floor(parsed));
|
return Math.max(100, Math.floor(parsed));
|
||||||
@ -329,6 +388,67 @@ export async function fetchFirecrawlContent(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchParallelContent(params: {
|
||||||
|
url: string;
|
||||||
|
extractMode: ExtractMode;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
}): Promise<{
|
||||||
|
text: string;
|
||||||
|
title?: string;
|
||||||
|
finalUrl?: string;
|
||||||
|
status?: number;
|
||||||
|
}> {
|
||||||
|
const endpoint = `${params.baseUrl.replace(/\/$/, "")}/v1beta/extract`;
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${params.apiKey}`,
|
||||||
|
"parallel-beta": "search-extract-2025-10-10",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
urls: [params.url],
|
||||||
|
full_content: true,
|
||||||
|
}),
|
||||||
|
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await res.json()) as ParallelExtractResponse;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorDetail = payload?.errors?.[0];
|
||||||
|
const detail = errorDetail
|
||||||
|
? `${errorDetail.error_type}: ${errorDetail.content || ""}`
|
||||||
|
: res.statusText;
|
||||||
|
throw new Error(`Parallel extract failed (${res.status}): ${detail}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.errors?.length && !payload.results?.length) {
|
||||||
|
const errorDetail = payload.errors[0];
|
||||||
|
throw new Error(
|
||||||
|
`Parallel extract failed: ${errorDetail?.error_type || "unknown error"}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = payload.results?.[0];
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Parallel extract returned no results");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = result.full_content ?? result.excerpts?.join("\n\n") ?? "";
|
||||||
|
const text = params.extractMode === "text" ? markdownToText(rawText) : rawText;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
title: result.title ?? undefined,
|
||||||
|
finalUrl: result.url,
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function runWebFetch(params: {
|
async function runWebFetch(params: {
|
||||||
url: string;
|
url: string;
|
||||||
extractMode: ExtractMode;
|
extractMode: ExtractMode;
|
||||||
@ -346,6 +466,10 @@ async function runWebFetch(params: {
|
|||||||
firecrawlProxy: "auto" | "basic" | "stealth";
|
firecrawlProxy: "auto" | "basic" | "stealth";
|
||||||
firecrawlStoreInCache: boolean;
|
firecrawlStoreInCache: boolean;
|
||||||
firecrawlTimeoutSeconds: number;
|
firecrawlTimeoutSeconds: number;
|
||||||
|
parallelExtractEnabled: boolean;
|
||||||
|
parallelExtractApiKey?: string;
|
||||||
|
parallelExtractBaseUrl: string;
|
||||||
|
parallelExtractTimeoutSeconds: number;
|
||||||
}): Promise<Record<string, unknown>> {
|
}): Promise<Record<string, unknown>> {
|
||||||
const cacheKey = normalizeCacheKey(
|
const cacheKey = normalizeCacheKey(
|
||||||
`fetch:${params.url}:${params.extractMode}:${params.maxChars}`,
|
`fetch:${params.url}:${params.extractMode}:${params.maxChars}`,
|
||||||
@ -464,7 +588,28 @@ async function runWebFetch(params: {
|
|||||||
let extractor = "raw";
|
let extractor = "raw";
|
||||||
let text = body;
|
let text = body;
|
||||||
if (contentType.includes("text/html")) {
|
if (contentType.includes("text/html")) {
|
||||||
if (params.readabilityEnabled) {
|
// Try Parallel extract first if enabled
|
||||||
|
if (params.parallelExtractEnabled && params.parallelExtractApiKey) {
|
||||||
|
try {
|
||||||
|
const parallel = await fetchParallelContent({
|
||||||
|
url: finalUrl,
|
||||||
|
extractMode: params.extractMode,
|
||||||
|
apiKey: params.parallelExtractApiKey,
|
||||||
|
baseUrl: params.parallelExtractBaseUrl,
|
||||||
|
timeoutSeconds: params.parallelExtractTimeoutSeconds,
|
||||||
|
});
|
||||||
|
if (parallel.text) {
|
||||||
|
text = parallel.text;
|
||||||
|
title = parallel.title;
|
||||||
|
extractor = "parallel";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to Readability/Firecrawl if Parallel fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Parallel didn't succeed, try Readability
|
||||||
|
if (extractor === "raw" && params.readabilityEnabled) {
|
||||||
const readable = await extractReadableContent({
|
const readable = await extractReadableContent({
|
||||||
html: body,
|
html: body,
|
||||||
url: finalUrl,
|
url: finalUrl,
|
||||||
@ -480,13 +625,13 @@ async function runWebFetch(params: {
|
|||||||
text = firecrawl.text;
|
text = firecrawl.text;
|
||||||
title = firecrawl.title;
|
title = firecrawl.title;
|
||||||
extractor = "firecrawl";
|
extractor = "firecrawl";
|
||||||
} else {
|
} else if (!params.parallelExtractEnabled) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Web fetch extraction failed: Readability and Firecrawl returned no content.",
|
"Web fetch extraction failed: Readability and Firecrawl returned no content.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (extractor === "raw" && !params.parallelExtractEnabled) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
|
"Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
|
||||||
);
|
);
|
||||||
@ -586,6 +731,17 @@ export function createWebFetchTool(options?: {
|
|||||||
firecrawl?.timeoutSeconds ?? fetch?.timeoutSeconds,
|
firecrawl?.timeoutSeconds ?? fetch?.timeoutSeconds,
|
||||||
DEFAULT_TIMEOUT_SECONDS,
|
DEFAULT_TIMEOUT_SECONDS,
|
||||||
);
|
);
|
||||||
|
const parallelExtract = resolveParallelExtractConfig(fetch);
|
||||||
|
const parallelExtractApiKey = resolveParallelExtractApiKey(parallelExtract);
|
||||||
|
const parallelExtractEnabled = resolveParallelExtractEnabled({
|
||||||
|
parallel: parallelExtract,
|
||||||
|
apiKey: parallelExtractApiKey,
|
||||||
|
});
|
||||||
|
const parallelExtractBaseUrl = resolveParallelExtractBaseUrl(parallelExtract);
|
||||||
|
const parallelExtractTimeoutSeconds = resolveTimeoutSeconds(
|
||||||
|
parallelExtract?.timeoutSeconds ?? fetch?.timeoutSeconds,
|
||||||
|
DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
);
|
||||||
const userAgent =
|
const userAgent =
|
||||||
(fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) ||
|
(fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) ||
|
||||||
DEFAULT_FETCH_USER_AGENT;
|
DEFAULT_FETCH_USER_AGENT;
|
||||||
@ -617,6 +773,10 @@ export function createWebFetchTool(options?: {
|
|||||||
firecrawlProxy: "auto",
|
firecrawlProxy: "auto",
|
||||||
firecrawlStoreInCache: true,
|
firecrawlStoreInCache: true,
|
||||||
firecrawlTimeoutSeconds,
|
firecrawlTimeoutSeconds,
|
||||||
|
parallelExtractEnabled,
|
||||||
|
parallelExtractApiKey,
|
||||||
|
parallelExtractBaseUrl,
|
||||||
|
parallelExtractTimeoutSeconds,
|
||||||
});
|
});
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
import { __testing } from "./web-search.js";
|
import { __testing } from "./web-search.js";
|
||||||
|
|
||||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
|
const {
|
||||||
__testing;
|
inferPerplexityBaseUrlFromApiKey,
|
||||||
|
resolvePerplexityBaseUrl,
|
||||||
|
normalizeFreshness,
|
||||||
|
resolveParallelApiKey,
|
||||||
|
resolveParallelBaseUrl,
|
||||||
|
} = __testing;
|
||||||
|
|
||||||
describe("web_search perplexity baseUrl defaults", () => {
|
describe("web_search perplexity baseUrl defaults", () => {
|
||||||
it("detects a Perplexity key prefix", () => {
|
it("detects a Perplexity key prefix", () => {
|
||||||
@ -69,3 +74,44 @@ describe("web_search freshness normalization", () => {
|
|||||||
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
|
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("web_search parallel config resolution", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.PARALLEL_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns config apiKey when provided", () => {
|
||||||
|
expect(resolveParallelApiKey({ apiKey: "test-key" })).toBe("test-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns env apiKey when config is empty", () => {
|
||||||
|
process.env.PARALLEL_API_KEY = "env-key";
|
||||||
|
expect(resolveParallelApiKey({})).toBe("env-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no key is available", () => {
|
||||||
|
expect(resolveParallelApiKey({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns config baseUrl when provided", () => {
|
||||||
|
expect(resolveParallelBaseUrl({ baseUrl: "https://custom.api.com" })).toBe(
|
||||||
|
"https://custom.api.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default baseUrl when config is empty", () => {
|
||||||
|
expect(resolveParallelBaseUrl({})).toBe("https://api.parallel.ai");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default baseUrl for undefined config", () => {
|
||||||
|
expect(resolveParallelBaseUrl(undefined)).toBe("https://api.parallel.ai");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
writeCache,
|
writeCache,
|
||||||
} from "./web-shared.js";
|
} from "./web-shared.js";
|
||||||
|
|
||||||
const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
|
const SEARCH_PROVIDERS = ["brave", "perplexity", "parallel"] as const;
|
||||||
const DEFAULT_SEARCH_COUNT = 5;
|
const DEFAULT_SEARCH_COUNT = 5;
|
||||||
const MAX_SEARCH_COUNT = 10;
|
const MAX_SEARCH_COUNT = 10;
|
||||||
|
|
||||||
@ -27,6 +27,7 @@ const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
|||||||
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
|
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
|
||||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||||
|
const DEFAULT_PARALLEL_BASE_URL = "https://api.parallel.ai";
|
||||||
|
|
||||||
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||||
@ -103,6 +104,22 @@ type PerplexitySearchResponse = {
|
|||||||
|
|
||||||
type PerplexityBaseUrlHint = "direct" | "openrouter";
|
type PerplexityBaseUrlHint = "direct" | "openrouter";
|
||||||
|
|
||||||
|
type ParallelConfig = {
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParallelSearchResponse = {
|
||||||
|
search_id: string;
|
||||||
|
results: Array<{
|
||||||
|
url: string;
|
||||||
|
title?: string | null;
|
||||||
|
excerpts?: string[] | null;
|
||||||
|
publish_date?: string | null;
|
||||||
|
}>;
|
||||||
|
warnings?: Array<{ message?: string }> | null;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveSearchConfig(cfg?: ClawdbotConfig): WebSearchConfig {
|
function resolveSearchConfig(cfg?: ClawdbotConfig): WebSearchConfig {
|
||||||
const search = cfg?.tools?.web?.search;
|
const search = cfg?.tools?.web?.search;
|
||||||
if (!search || typeof search !== "object") return undefined;
|
if (!search || typeof search !== "object") return undefined;
|
||||||
@ -131,6 +148,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
|
|||||||
docs: "https://docs.clawd.bot/tools/web",
|
docs: "https://docs.clawd.bot/tools/web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (provider === "parallel") {
|
||||||
|
return {
|
||||||
|
error: "missing_parallel_api_key",
|
||||||
|
message:
|
||||||
|
"web_search (parallel) needs an API key. Set PARALLEL_API_KEY in the Gateway environment, or configure tools.web.search.parallel.apiKey.",
|
||||||
|
docs: "https://docs.clawd.bot/tools/web",
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
error: "missing_brave_api_key",
|
error: "missing_brave_api_key",
|
||||||
message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("clawdbot configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("clawdbot configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
||||||
@ -144,6 +169,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
|||||||
? search.provider.trim().toLowerCase()
|
? search.provider.trim().toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (raw === "perplexity") return "perplexity";
|
if (raw === "perplexity") return "perplexity";
|
||||||
|
if (raw === "parallel") return "parallel";
|
||||||
if (raw === "brave") return "brave";
|
if (raw === "brave") return "brave";
|
||||||
return "brave";
|
return "brave";
|
||||||
}
|
}
|
||||||
@ -221,6 +247,28 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
|
|||||||
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
|
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveParallelConfig(search?: WebSearchConfig): ParallelConfig {
|
||||||
|
if (!search || typeof search !== "object") return {};
|
||||||
|
const parallel = "parallel" in search ? search.parallel : undefined;
|
||||||
|
if (!parallel || typeof parallel !== "object") return {};
|
||||||
|
return parallel as ParallelConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParallelApiKey(parallel?: ParallelConfig): string | undefined {
|
||||||
|
const fromConfig = normalizeApiKey(parallel?.apiKey);
|
||||||
|
if (fromConfig) return fromConfig;
|
||||||
|
const fromEnv = normalizeApiKey(process.env.PARALLEL_API_KEY);
|
||||||
|
return fromEnv || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParallelBaseUrl(parallel?: ParallelConfig): string {
|
||||||
|
const fromConfig =
|
||||||
|
parallel && "baseUrl" in parallel && typeof parallel.baseUrl === "string"
|
||||||
|
? parallel.baseUrl.trim()
|
||||||
|
: "";
|
||||||
|
return fromConfig || DEFAULT_PARALLEL_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSearchCount(value: unknown, fallback: number): number {
|
function resolveSearchCount(value: unknown, fallback: number): number {
|
||||||
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
|
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
|
||||||
@ -306,6 +354,60 @@ async function runPerplexitySearch(params: {
|
|||||||
return { content, citations };
|
return { content, citations };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runParallelSearch(params: {
|
||||||
|
query: string;
|
||||||
|
count: number;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
}): Promise<{
|
||||||
|
results: Array<{
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
published?: string;
|
||||||
|
siteName?: string;
|
||||||
|
}>;
|
||||||
|
searchId: string;
|
||||||
|
}> {
|
||||||
|
const endpoint = `${params.baseUrl.replace(/\/$/, "")}/v1beta/search`;
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${params.apiKey}`,
|
||||||
|
"parallel-beta": "search-extract-2025-10-10",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
objective: params.query,
|
||||||
|
search_queries: [params.query],
|
||||||
|
max_results: params.count,
|
||||||
|
excerpts: {
|
||||||
|
max_chars_per_result: 500,
|
||||||
|
},
|
||||||
|
mode: "agentic",
|
||||||
|
}),
|
||||||
|
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await readResponseText(res);
|
||||||
|
throw new Error(`Parallel Search API error (${res.status}): ${detail || res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as ParallelSearchResponse;
|
||||||
|
const results = (data.results ?? []).map((entry) => ({
|
||||||
|
title: entry.title ?? "",
|
||||||
|
url: entry.url ?? "",
|
||||||
|
description: entry.excerpts?.join("\n\n") ?? "",
|
||||||
|
published: entry.publish_date ?? undefined,
|
||||||
|
siteName: resolveSiteName(entry.url ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { results, searchId: data.search_id };
|
||||||
|
}
|
||||||
|
|
||||||
async function runWebSearch(params: {
|
async function runWebSearch(params: {
|
||||||
query: string;
|
query: string;
|
||||||
count: number;
|
count: number;
|
||||||
@ -319,6 +421,7 @@ async function runWebSearch(params: {
|
|||||||
freshness?: string;
|
freshness?: string;
|
||||||
perplexityBaseUrl?: string;
|
perplexityBaseUrl?: string;
|
||||||
perplexityModel?: string;
|
perplexityModel?: string;
|
||||||
|
parallelBaseUrl?: string;
|
||||||
}): Promise<Record<string, unknown>> {
|
}): Promise<Record<string, unknown>> {
|
||||||
const cacheKey = normalizeCacheKey(
|
const cacheKey = normalizeCacheKey(
|
||||||
params.provider === "brave"
|
params.provider === "brave"
|
||||||
@ -351,6 +454,27 @@ async function runWebSearch(params: {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.provider === "parallel") {
|
||||||
|
const { results, searchId } = await runParallelSearch({
|
||||||
|
query: params.query,
|
||||||
|
count: params.count,
|
||||||
|
apiKey: params.apiKey,
|
||||||
|
baseUrl: params.parallelBaseUrl ?? DEFAULT_PARALLEL_BASE_URL,
|
||||||
|
timeoutSeconds: params.timeoutSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
query: params.query,
|
||||||
|
provider: params.provider,
|
||||||
|
searchId,
|
||||||
|
count: results.length,
|
||||||
|
tookMs: Date.now() - start,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
if (params.provider !== "brave") {
|
if (params.provider !== "brave") {
|
||||||
throw new Error("Unsupported web search provider.");
|
throw new Error("Unsupported web search provider.");
|
||||||
}
|
}
|
||||||
@ -415,11 +539,14 @@ export function createWebSearchTool(options?: {
|
|||||||
|
|
||||||
const provider = resolveSearchProvider(search);
|
const provider = resolveSearchProvider(search);
|
||||||
const perplexityConfig = resolvePerplexityConfig(search);
|
const perplexityConfig = resolvePerplexityConfig(search);
|
||||||
|
const parallelConfig = resolveParallelConfig(search);
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
provider === "perplexity"
|
provider === "perplexity"
|
||||||
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
|
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
|
||||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
|
: provider === "parallel"
|
||||||
|
? "Search the web using Parallel Search API. Returns relevant excerpts and titles from web search results."
|
||||||
|
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: "Web Search",
|
label: "Web Search",
|
||||||
@ -429,8 +556,14 @@ export function createWebSearchTool(options?: {
|
|||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const perplexityAuth =
|
const perplexityAuth =
|
||||||
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
|
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
|
||||||
|
const parallelApiKey =
|
||||||
|
provider === "parallel" ? resolveParallelApiKey(parallelConfig) : undefined;
|
||||||
const apiKey =
|
const apiKey =
|
||||||
provider === "perplexity" ? perplexityAuth?.apiKey : resolveSearchApiKey(search);
|
provider === "perplexity"
|
||||||
|
? perplexityAuth?.apiKey
|
||||||
|
: provider === "parallel"
|
||||||
|
? parallelApiKey
|
||||||
|
: resolveSearchApiKey(search);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return jsonResult(missingSearchKeyPayload(provider));
|
return jsonResult(missingSearchKeyPayload(provider));
|
||||||
@ -476,6 +609,7 @@ export function createWebSearchTool(options?: {
|
|||||||
perplexityAuth?.apiKey,
|
perplexityAuth?.apiKey,
|
||||||
),
|
),
|
||||||
perplexityModel: resolvePerplexityModel(perplexityConfig),
|
perplexityModel: resolvePerplexityModel(perplexityConfig),
|
||||||
|
parallelBaseUrl: resolveParallelBaseUrl(parallelConfig),
|
||||||
});
|
});
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
},
|
},
|
||||||
@ -486,4 +620,6 @@ export const __testing = {
|
|||||||
inferPerplexityBaseUrlFromApiKey,
|
inferPerplexityBaseUrlFromApiKey,
|
||||||
resolvePerplexityBaseUrl,
|
resolvePerplexityBaseUrl,
|
||||||
normalizeFreshness,
|
normalizeFreshness,
|
||||||
|
resolveParallelApiKey,
|
||||||
|
resolveParallelBaseUrl,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{
|
|||||||
}> = [
|
}> = [
|
||||||
{ value: "workspace", label: "Workspace", hint: "Set workspace + sessions" },
|
{ value: "workspace", label: "Workspace", hint: "Set workspace + sessions" },
|
||||||
{ value: "model", label: "Model", hint: "Pick provider + credentials" },
|
{ value: "model", label: "Model", hint: "Pick provider + credentials" },
|
||||||
{ value: "web", label: "Web tools", hint: "Configure Brave search + fetch" },
|
{ value: "web", label: "Web tools", hint: "Configure web search + fetch" },
|
||||||
{ value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" },
|
{ value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" },
|
||||||
{
|
{
|
||||||
value: "daemon",
|
value: "daemon",
|
||||||
|
|||||||
@ -88,18 +88,43 @@ async function promptChannelMode(runtime: RuntimeEnv): Promise<ChannelsWizardMod
|
|||||||
) as ChannelsWizardMode;
|
) as ChannelsWizardMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_PROVIDER_OPTIONS = [
|
||||||
|
{ value: "brave", label: "Brave Search", hint: "Fast, structured results, free tier" },
|
||||||
|
{ value: "perplexity", label: "Perplexity Sonar", hint: "AI-synthesized answers with citations" },
|
||||||
|
{ value: "parallel", label: "Parallel", hint: "Relevant excerpts, agentic mode" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SearchProvider = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
const PROVIDER_ENV_VARS: Record<SearchProvider, string> = {
|
||||||
|
brave: "BRAVE_API_KEY",
|
||||||
|
perplexity: "PERPLEXITY_API_KEY or OPENROUTER_API_KEY",
|
||||||
|
parallel: "PARALLEL_API_KEY",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_PLACEHOLDERS: Record<SearchProvider, string> = {
|
||||||
|
brave: "BSA...",
|
||||||
|
perplexity: "pplx-... or sk-or-...",
|
||||||
|
parallel: "your-parallel-key",
|
||||||
|
};
|
||||||
|
|
||||||
async function promptWebToolsConfig(
|
async function promptWebToolsConfig(
|
||||||
nextConfig: ClawdbotConfig,
|
nextConfig: ClawdbotConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
): Promise<ClawdbotConfig> {
|
): Promise<ClawdbotConfig> {
|
||||||
const existingSearch = nextConfig.tools?.web?.search;
|
const existingSearch = nextConfig.tools?.web?.search;
|
||||||
const existingFetch = nextConfig.tools?.web?.fetch;
|
const existingFetch = nextConfig.tools?.web?.fetch;
|
||||||
const hasSearchKey = Boolean(existingSearch?.apiKey);
|
const existingProvider = existingSearch?.provider ?? "brave";
|
||||||
|
const hasSearchKey = Boolean(
|
||||||
|
existingSearch?.apiKey ||
|
||||||
|
existingSearch?.perplexity?.apiKey ||
|
||||||
|
existingSearch?.parallel?.apiKey,
|
||||||
|
);
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"Web search lets your agent look things up online using the `web_search` tool.",
|
"Web search lets your agent look things up online using the `web_search` tool.",
|
||||||
"It requires a Brave Search API key (you can store it in the config or set BRAVE_API_KEY in the Gateway environment).",
|
"Choose a provider and configure its API key.",
|
||||||
"Docs: https://docs.clawd.bot/tools/web",
|
"Docs: https://docs.clawd.bot/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
@ -107,35 +132,73 @@ async function promptWebToolsConfig(
|
|||||||
|
|
||||||
const enableSearch = guardCancel(
|
const enableSearch = guardCancel(
|
||||||
await confirm({
|
await confirm({
|
||||||
message: "Enable web_search (Brave Search)?",
|
message: "Enable web_search?",
|
||||||
initialValue: existingSearch?.enabled ?? hasSearchKey,
|
initialValue: existingSearch?.enabled ?? hasSearchKey,
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
let nextSearch = {
|
let nextSearch: NonNullable<NonNullable<ClawdbotConfig["tools"]>["web"]>["search"] = {
|
||||||
...existingSearch,
|
...existingSearch,
|
||||||
enabled: enableSearch,
|
enabled: enableSearch,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (enableSearch) {
|
if (enableSearch) {
|
||||||
|
const provider = guardCancel(
|
||||||
|
await select({
|
||||||
|
message: "Search provider",
|
||||||
|
options: SEARCH_PROVIDER_OPTIONS.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
hint: opt.hint,
|
||||||
|
})),
|
||||||
|
initialValue: existingProvider,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
) as SearchProvider;
|
||||||
|
|
||||||
|
nextSearch = { ...nextSearch, provider };
|
||||||
|
|
||||||
|
const envVar = PROVIDER_ENV_VARS[provider];
|
||||||
|
const placeholder = PROVIDER_PLACEHOLDERS[provider];
|
||||||
|
const currentKey =
|
||||||
|
provider === "brave"
|
||||||
|
? existingSearch?.apiKey
|
||||||
|
: provider === "perplexity"
|
||||||
|
? existingSearch?.perplexity?.apiKey
|
||||||
|
: existingSearch?.parallel?.apiKey;
|
||||||
|
const hasCurrentKey = Boolean(currentKey);
|
||||||
|
|
||||||
const keyInput = guardCancel(
|
const keyInput = guardCancel(
|
||||||
await text({
|
await text({
|
||||||
message: hasSearchKey
|
message: hasCurrentKey
|
||||||
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
|
? `API key (leave blank to keep current or use ${envVar})`
|
||||||
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
|
: `API key (paste it here; leave blank to use ${envVar})`,
|
||||||
placeholder: hasSearchKey ? "Leave blank to keep current" : "BSA...",
|
placeholder: hasCurrentKey ? "Leave blank to keep current" : placeholder,
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
const key = String(keyInput ?? "").trim();
|
const key = String(keyInput ?? "").trim();
|
||||||
if (key) {
|
|
||||||
nextSearch = { ...nextSearch, apiKey: key };
|
if (key || hasCurrentKey) {
|
||||||
} else if (!hasSearchKey) {
|
if (provider === "brave") {
|
||||||
|
nextSearch = { ...nextSearch, apiKey: key || currentKey };
|
||||||
|
} else if (provider === "perplexity") {
|
||||||
|
nextSearch = {
|
||||||
|
...nextSearch,
|
||||||
|
perplexity: { ...existingSearch?.perplexity, apiKey: key || currentKey },
|
||||||
|
};
|
||||||
|
} else if (provider === "parallel") {
|
||||||
|
nextSearch = {
|
||||||
|
...nextSearch,
|
||||||
|
parallel: { ...existingSearch?.parallel, apiKey: key || currentKey },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"No key stored yet, so web_search will stay unavailable.",
|
"No key stored yet, so web_search will stay unavailable.",
|
||||||
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
|
`Store a key here or set ${envVar} in the Gateway environment.`,
|
||||||
"Docs: https://docs.clawd.bot/tools/web",
|
"Docs: https://docs.clawd.bot/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
|
|||||||
|
|
||||||
if (!opts.json) {
|
if (!opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`Tip: run \`${formatCliCommand("clawdbot configure --section web")}\` to store your Brave API key for web_search. Docs: https://docs.clawd.bot/tools/web`,
|
`Tip: run \`${formatCliCommand("clawdbot configure --section web")}\` to configure web_search (Brave, Perplexity, or Parallel). Docs: https://docs.clawd.bot/tools/web`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export async function runNonInteractiveOnboardingRemote(params: {
|
|||||||
runtime.log(`Remote gateway: ${remoteUrl}`);
|
runtime.log(`Remote gateway: ${remoteUrl}`);
|
||||||
runtime.log(`Auth: ${payload.auth}`);
|
runtime.log(`Auth: ${payload.auth}`);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`Tip: run \`${formatCliCommand("clawdbot configure --section web")}\` to store your Brave API key for web_search. Docs: https://docs.clawd.bot/tools/web`,
|
`Tip: run \`${formatCliCommand("clawdbot configure --section web")}\` to configure web_search (Brave, Perplexity, or Parallel). Docs: https://docs.clawd.bot/tools/web`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,4 +22,43 @@ describe("web search provider config", () => {
|
|||||||
|
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts parallel provider and config", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
enabled: true,
|
||||||
|
provider: "parallel",
|
||||||
|
parallel: {
|
||||||
|
apiKey: "test-parallel-key",
|
||||||
|
baseUrl: "https://api.parallel.ai",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts parallel extract config for fetch", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
fetch: {
|
||||||
|
enabled: true,
|
||||||
|
parallel: {
|
||||||
|
enabled: true,
|
||||||
|
apiKey: "test-parallel-key",
|
||||||
|
baseUrl: "https://api.parallel.ai",
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -332,8 +332,8 @@ export type ToolsConfig = {
|
|||||||
search?: {
|
search?: {
|
||||||
/** Enable web search tool (default: true when API key is present). */
|
/** Enable web search tool (default: true when API key is present). */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Search provider ("brave" or "perplexity"). */
|
/** Search provider ("brave", "perplexity", or "parallel"). */
|
||||||
provider?: "brave" | "perplexity";
|
provider?: "brave" | "perplexity" | "parallel";
|
||||||
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
/** Default search results count (1-10). */
|
/** Default search results count (1-10). */
|
||||||
@ -351,6 +351,13 @@ export type ToolsConfig = {
|
|||||||
/** Model to use (defaults to "perplexity/sonar-pro"). */
|
/** Model to use (defaults to "perplexity/sonar-pro"). */
|
||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
|
/** Parallel-specific configuration (used when provider="parallel"). */
|
||||||
|
parallel?: {
|
||||||
|
/** API key for Parallel (defaults to PARALLEL_API_KEY env var). */
|
||||||
|
apiKey?: string;
|
||||||
|
/** Base URL for API requests (defaults to https://api.parallel.ai). */
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
fetch?: {
|
fetch?: {
|
||||||
/** Enable web fetch tool (default: true). */
|
/** Enable web fetch tool (default: true). */
|
||||||
@ -381,6 +388,17 @@ export type ToolsConfig = {
|
|||||||
/** Timeout in seconds for Firecrawl requests. */
|
/** Timeout in seconds for Firecrawl requests. */
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
};
|
};
|
||||||
|
/** Parallel extract configuration (alternative to Readability/Firecrawl). */
|
||||||
|
parallel?: {
|
||||||
|
/** Enable Parallel extract (default: false). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Parallel API key (defaults to PARALLEL_API_KEY env var). */
|
||||||
|
apiKey?: string;
|
||||||
|
/** Parallel API base URL (defaults to https://api.parallel.ai). */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** Timeout in seconds for Parallel extract requests. */
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
media?: MediaToolsConfig;
|
media?: MediaToolsConfig;
|
||||||
|
|||||||
@ -159,7 +159,8 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
|||||||
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
message:
|
||||||
|
"tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).optional();
|
}).optional();
|
||||||
@ -167,7 +168,9 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
|||||||
export const ToolsWebSearchSchema = z
|
export const ToolsWebSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(),
|
provider: z
|
||||||
|
.union([z.literal("brave"), z.literal("perplexity"), z.literal("parallel")])
|
||||||
|
.optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
maxResults: z.number().int().positive().optional(),
|
maxResults: z.number().int().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
@ -180,6 +183,13 @@ export const ToolsWebSearchSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
parallel: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
@ -192,6 +202,15 @@ export const ToolsWebFetchSchema = z
|
|||||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||||
maxRedirects: z.number().int().nonnegative().optional(),
|
maxRedirects: z.number().int().nonnegative().optional(),
|
||||||
userAgent: z.string().optional(),
|
userAgent: z.string().optional(),
|
||||||
|
parallel: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
@ -436,9 +436,11 @@ function hasWebSearchKey(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
|
|||||||
return Boolean(
|
return Boolean(
|
||||||
search?.apiKey ||
|
search?.apiKey ||
|
||||||
search?.perplexity?.apiKey ||
|
search?.perplexity?.apiKey ||
|
||||||
|
search?.parallel?.apiKey ||
|
||||||
env.BRAVE_API_KEY ||
|
env.BRAVE_API_KEY ||
|
||||||
env.PERPLEXITY_API_KEY ||
|
env.PERPLEXITY_API_KEY ||
|
||||||
env.OPENROUTER_API_KEY,
|
env.OPENROUTER_API_KEY ||
|
||||||
|
env.PARALLEL_API_KEY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -432,8 +432,20 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
|
const searchConfig = nextConfig.tools?.web?.search;
|
||||||
const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim();
|
const webSearchKey = (
|
||||||
|
searchConfig?.apiKey ||
|
||||||
|
searchConfig?.perplexity?.apiKey ||
|
||||||
|
searchConfig?.parallel?.apiKey ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const webSearchEnv = (
|
||||||
|
process.env.BRAVE_API_KEY ||
|
||||||
|
process.env.PERPLEXITY_API_KEY ||
|
||||||
|
process.env.OPENROUTER_API_KEY ||
|
||||||
|
process.env.PARALLEL_API_KEY ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv);
|
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv);
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
hasWebSearchKey
|
hasWebSearchKey
|
||||||
@ -441,20 +453,20 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
"Web search is enabled, so your agent can look things up online when needed.",
|
"Web search is enabled, so your agent can look things up online when needed.",
|
||||||
"",
|
"",
|
||||||
webSearchKey
|
webSearchKey
|
||||||
? "API key: stored in config (tools.web.search.apiKey)."
|
? "API key: stored in config."
|
||||||
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
|
: "API key: provided via environment variable.",
|
||||||
"Docs: https://docs.clawd.bot/tools/web",
|
"Docs: https://docs.clawd.bot/tools/web",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
: [
|
: [
|
||||||
"If you want your agent to be able to search the web, you’ll need an API key.",
|
"If you want your agent to be able to search the web, you'll need an API key.",
|
||||||
"",
|
"",
|
||||||
"Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.",
|
"Clawdbot supports Brave Search, Perplexity, and Parallel for the `web_search` tool.",
|
||||||
"",
|
"",
|
||||||
"Set it up interactively:",
|
"Set it up interactively:",
|
||||||
`- Run: ${formatCliCommand("clawdbot configure --section web")}`,
|
`- Run: ${formatCliCommand("clawdbot configure --section web")}`,
|
||||||
"- Enable web_search and paste your Brave Search API key",
|
"- Choose a provider and paste your API key",
|
||||||
"",
|
"",
|
||||||
"Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
|
"Alternative: set BRAVE_API_KEY, PERPLEXITY_API_KEY, or PARALLEL_API_KEY in the Gateway environment.",
|
||||||
"Docs: https://docs.clawd.bot/tools/web",
|
"Docs: https://docs.clawd.bot/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search (optional)",
|
"Web search (optional)",
|
||||||
|
|||||||
@ -284,7 +284,13 @@ describe("runOnboardingWizard", () => {
|
|||||||
|
|
||||||
it("shows the web search hint at the end of onboarding", async () => {
|
it("shows the web search hint at the end of onboarding", async () => {
|
||||||
const prevBraveKey = process.env.BRAVE_API_KEY;
|
const prevBraveKey = process.env.BRAVE_API_KEY;
|
||||||
|
const prevPerplexityKey = process.env.PERPLEXITY_API_KEY;
|
||||||
|
const prevOpenRouterKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
const prevParallelKey = process.env.PARALLEL_API_KEY;
|
||||||
delete process.env.BRAVE_API_KEY;
|
delete process.env.BRAVE_API_KEY;
|
||||||
|
delete process.env.PERPLEXITY_API_KEY;
|
||||||
|
delete process.env.OPENROUTER_API_KEY;
|
||||||
|
delete process.env.PARALLEL_API_KEY;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const note: WizardPrompter["note"] = vi.fn(async () => {});
|
const note: WizardPrompter["note"] = vi.fn(async () => {});
|
||||||
@ -329,6 +335,21 @@ describe("runOnboardingWizard", () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.BRAVE_API_KEY = prevBraveKey;
|
process.env.BRAVE_API_KEY = prevBraveKey;
|
||||||
}
|
}
|
||||||
|
if (prevPerplexityKey === undefined) {
|
||||||
|
delete process.env.PERPLEXITY_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.PERPLEXITY_API_KEY = prevPerplexityKey;
|
||||||
|
}
|
||||||
|
if (prevOpenRouterKey === undefined) {
|
||||||
|
delete process.env.OPENROUTER_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.OPENROUTER_API_KEY = prevOpenRouterKey;
|
||||||
|
}
|
||||||
|
if (prevParallelKey === undefined) {
|
||||||
|
delete process.env.PARALLEL_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.PARALLEL_API_KEY = prevParallelKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user