diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 7e8f696e8..de52d6bb0 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -15,6 +15,7 @@ const { resolveKimiModel, resolveKimiBaseUrl, extractKimiCitations, + resolveBraveMode, } = __testing; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); @@ -276,3 +277,25 @@ describe("extractKimiCitations", () => { ).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]); }); }); + +describe("resolveBraveMode", () => { + it("defaults to 'web' when no config is provided", () => { + expect(resolveBraveMode({})).toBe("web"); + }); + + it("defaults to 'web' when mode is undefined", () => { + expect(resolveBraveMode({ mode: undefined })).toBe("web"); + }); + + it("returns 'llm-context' when configured", () => { + expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context"); + }); + + it("returns 'web' when mode is explicitly 'web'", () => { + expect(resolveBraveMode({ mode: "web" })).toBe("web"); + }); + + it("falls back to 'web' for unrecognized mode values", () => { + expect(resolveBraveMode({ mode: "invalid" })).toBe("web"); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1e4983f85..c70f25604 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -26,6 +26,7 @@ const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; +const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; @@ -247,6 +248,17 @@ type BraveSearchResponse = { }; }; +type BraveLlmContextSnippet = { text: string }; +type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] }; +type BraveLlmContextResponse = { + grounding: { generic?: BraveLlmContextResult[] }; + sources?: { url?: string; hostname?: string; date?: string }[]; +}; + +type BraveConfig = { + mode?: string; +}; + type PerplexityConfig = { apiKey?: string; }; @@ -550,6 +562,21 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE return "perplexity"; } +function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { + if (!search || typeof search !== "object") { + return {}; + } + const brave = "brave" in search ? search.brave : undefined; + if (!brave || typeof brave !== "object") { + return {}; + } + return brave as BraveConfig; +} + +function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { + return brave.mode === "llm-context" ? "llm-context" : "web"; +} + function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { if (!search || typeof search !== "object") { return {}; @@ -1213,6 +1240,67 @@ async function runKimiSearch(params: { }; } +async function runBraveLlmContextSearch(params: { + query: string; + apiKey: string; + timeoutSeconds: number; + country?: string; + search_lang?: string; + freshness?: string; +}): Promise<{ + results: Array<{ + url: string; + title: string; + snippets: string[]; + siteName?: string; + }>; + sources?: BraveLlmContextResponse["sources"]; +}> { + const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); + url.searchParams.set("q", params.query); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang) { + url.searchParams.set("search_lang", params.search_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } + + return withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as BraveLlmContextResponse; + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + const mapped = genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean), + siteName: resolveSiteName(entry.url) || undefined, + })); + + return { results: mapped, sources: data.sources }; + }, + ); +} + async function runWebSearch(params: { query: string; count: number; @@ -1235,7 +1323,9 @@ async function runWebSearch(params: { geminiModel?: string; kimiBaseUrl?: string; kimiModel?: string; + braveMode?: "web" | "llm-context"; }): Promise> { + const effectiveBraveMode = params.braveMode ?? "web"; const providerSpecificKey = params.provider === "grok" ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` @@ -1245,7 +1335,9 @@ async function runWebSearch(params: { ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` : ""; const cacheKey = normalizeCacheKey( - `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, + params.provider === "brave" && effectiveBraveMode === "llm-context" + ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` + : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) { @@ -1372,6 +1464,42 @@ async function runWebSearch(params: { throw new Error("Unsupported web search provider."); } + if (effectiveBraveMode === "llm-context") { + const { results: llmResults, sources } = await runBraveLlmContextSearch({ + query: params.query, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + country: params.country, + search_lang: params.search_lang, + freshness: params.freshness, + }); + + const mapped = llmResults.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")), + siteName: entry.siteName, + })); + + const payload = { + query: params.query, + provider: params.provider, + mode: "llm-context" as const, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + sources, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + const url = new URL(BRAVE_SEARCH_ENDPOINT); url.searchParams.set("q", params.query); url.searchParams.set("count", String(params.count)); @@ -1465,6 +1593,8 @@ export function createWebSearchTool(options?: { const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); const kimiConfig = resolveKimiConfig(search); + const braveConfig = resolveBraveConfig(search); + const braveMode = resolveBraveMode(braveConfig); const description = provider === "perplexity" @@ -1475,7 +1605,9 @@ export function createWebSearchTool(options?: { ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." : provider === "gemini" ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google 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."; + : braveMode === "llm-context" + ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." + : "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 { label: "Web Search", @@ -1660,6 +1792,7 @@ export function createWebSearchTool(options?: { geminiModel: resolveGeminiModel(geminiConfig), kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), kimiModel: resolveKimiModel(kimiConfig), + braveMode, }); return jsonResult(result); }, @@ -1684,4 +1817,5 @@ export const __testing = { resolveKimiBaseUrl, extractKimiCitations, resolveRedirectUrl: resolveCitationRedirectUrl, + resolveBraveMode, } as const; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 7d04ab5a9..cab191a1c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -666,6 +666,8 @@ export const FIELD_HELP: Record = { "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", "tools.web.search.perplexity.model": 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.search.brave.mode": + 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.maxCharsCap": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9266516b9..40169d3ef 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -224,6 +224,7 @@ export const FIELD_LABELS: Record = { "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret "tools.web.search.grok.model": "Grok Search Model", + "tools.web.search.brave.mode": "Brave Search Mode", "tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret "tools.web.search.kimi.baseUrl": "Kimi Search Base URL", "tools.web.search.kimi.model": "Kimi Search Model", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 5c8152f0e..e895e3bcf 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -485,6 +485,11 @@ export type ToolsConfig = { /** Model to use (defaults to "moonshot-v1-128k"). */ model?: string; }; + /** Brave-specific configuration (used when provider="brave"). */ + brave?: { + /** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */ + mode?: "web" | "llm-context"; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 227891711..421f8febf 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -308,6 +308,12 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + brave: z + .object({ + mode: z.union([z.literal("web"), z.literal("llm-context")]).optional(), + }) + .strict() + .optional(), }) .strict() .optional();