From 4397be1a247f7eb0162819a6ec2c7c1e99309810 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:39:26 +0100 Subject: [PATCH] fix(web-search): support Brave llm-context date filters --- CHANGELOG.md | 1 + docs/tools/brave-search.md | 2 +- docs/tools/web.md | 3 +- .../src/brave-web-search-provider.runtime.ts | 87 +++++++---- .../src/brave-web-search-provider.test.ts | 145 ++++++++++++++++++ 5 files changed, 207 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da65063436..153535dc10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. - Web search: late-bind managed agent `web_search` calls to the current runtime config snapshot, so existing sessions do not keep stale unresolved SecretRefs after secrets reload. Fixes #75420. Thanks @richardmqq. - Web search: honor `baseUrl` overrides for Gemini, Grok, and x_search provider-owned config, so proxy-backed search tools no longer dial hardcoded public endpoints. Supersedes #61972. Thanks @Lanfei. +- Web search/Brave: allow `freshness` and bounded date ranges in `llm-context` mode, matching Brave's documented LLM Context API support. Supersedes #51005. Thanks @remusao. - Web fetch: resolve external plugin `webFetchProviders` for non-sandboxed `web_fetch`, while keeping sandboxed fetches limited to bundled providers. Fixes #74915. Thanks @ultrahighsuper and @mingmingtsao. - Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. diff --git a/docs/tools/brave-search.md b/docs/tools/brave-search.md index 0f5e2788139..494d154ffb5 100644 --- a/docs/tools/brave-search.md +++ b/docs/tools/brave-search.md @@ -120,7 +120,7 @@ await web_search({ - Each Brave plan includes **\$5/month in free credit** (renewing). The Search plan costs \$5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans. - The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service). - `llm-context` mode returns grounded source entries instead of the normal web-search snippet shape. -- `llm-context` mode does not support `ui_lang`, `freshness`, `date_after`, or `date_before`. +- `llm-context` mode supports `freshness` and bounded `date_after` + `date_before` ranges. It does not support `ui_lang`; `date_before` without `date_after` is rejected because Brave requires custom freshness ranges to include both start and end dates. - `ui_lang` must include a region subtag like `en-US`. - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`). diff --git a/docs/tools/web.md b/docs/tools/web.md index 7d04df4cd60..0178b824835 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -296,7 +296,8 @@ show the `x_search` prompt. Not all parameters work with all providers. Brave `llm-context` mode - rejects `ui_lang`, `freshness`, `date_after`, and `date_before`. + rejects `ui_lang`; `date_before` also needs `date_after` because Brave custom + freshness ranges require both start and end dates. Gemini, Grok, and Kimi return one synthesized answer with citations. They accept `count` for shared-tool compatibility, but it does not change the grounded answer shape. diff --git a/extensions/brave/src/brave-web-search-provider.runtime.ts b/extensions/brave/src/brave-web-search-provider.runtime.ts index 43db33fd5b3..25b06601a9e 100644 --- a/extensions/brave/src/brave-web-search-provider.runtime.ts +++ b/extensions/brave/src/brave-web-search-provider.runtime.ts @@ -65,6 +65,8 @@ async function runBraveLlmContextSearch(params: { country?: string; search_lang?: string; freshness?: string; + dateAfter?: string; + dateBefore?: string; }): Promise<{ results: Array<{ url: string; @@ -84,6 +86,13 @@ async function runBraveLlmContextSearch(params: { } if (params.freshness) { url.searchParams.set("freshness", params.freshness); + } else if (params.dateAfter && params.dateBefore) { + url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); + } else if (params.dateAfter) { + url.searchParams.set( + "freshness", + `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, + ); } return withTrustedWebSearchEndpoint( @@ -235,14 +244,6 @@ export async function executeBraveSearch( } const rawFreshness = readStringParam(args, "freshness"); - if (rawFreshness && braveMode === "llm-context") { - return { - error: "unsupported_freshness", - message: - "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined; if (rawFreshness && !freshness) { return { @@ -262,15 +263,6 @@ export async function executeBraveSearch( docs: "https://docs.openclaw.ai/tools/web", }; } - if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") { - return { - error: "unsupported_date_filter", - message: - "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const parsedDateRange = parseIsoDateRange({ rawDateAfter, rawDateBefore, @@ -283,18 +275,53 @@ export async function executeBraveSearch( } const { dateAfter, dateBefore } = parsedDateRange; - const cacheKey = buildSearchCacheKey([ - "brave", - braveMode, - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - country, - normalizedLanguage.search_lang, - normalizedLanguage.ui_lang, - freshness, - dateAfter, - dateBefore, - ]); + if (braveMode === "llm-context") { + const today = new Date().toISOString().slice(0, 10); + if (dateAfter && !dateBefore && dateAfter > today) { + return { + error: "invalid_date_range", + message: "date_after cannot be in the future for Brave llm-context mode.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (dateBefore && !dateAfter) { + return { + error: "unsupported_date_filter", + message: + "Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + } + const llmContextDateEnd = + braveMode === "llm-context" && dateAfter + ? (dateBefore ?? new Date().toISOString().slice(0, 10)) + : dateBefore; + const cacheKey = buildSearchCacheKey( + braveMode === "llm-context" + ? [ + "brave", + braveMode, + query, + country, + normalizedLanguage.search_lang, + freshness, + dateAfter, + llmContextDateEnd, + ] + : [ + "brave", + braveMode, + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + country, + normalizedLanguage.search_lang, + normalizedLanguage.ui_lang, + freshness, + dateAfter, + dateBefore, + ], + ); const cached = readCachedSearchPayload(cacheKey); if (cached) { return cached; @@ -312,6 +339,8 @@ export async function executeBraveSearch( country: country ?? undefined, search_lang: normalizedLanguage.search_lang, freshness, + dateAfter, + dateBefore, }); const payload = { query, diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index a29fd1f7192..9ea5152305c 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -10,6 +10,28 @@ const braveManifest = JSON.parse( configSchema?: Record; }; +function installBraveLlmContextFetch() { + const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => { + return { + ok: true, + json: async () => ({ + grounding: { + generic: [ + { + url: "https://example.com/context", + title: "Context", + snippets: ["snippet"], + }, + ], + }, + sources: [], + }), + } as Response; + }); + global.fetch = mockFetch as typeof global.fetch; + return mockFetch; +} + describe("brave web search provider", () => { const priorFetch = global.fetch; @@ -176,6 +198,129 @@ describe("brave web search provider", () => { }); }); + it("passes freshness to Brave llm-context endpoint", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch(); + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { + apiKey: "BSA...", + brave: { mode: "llm-context" }, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + await tool.execute({ query: "latest ai news", freshness: "week" }); + + const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])); + expect(requestUrl.pathname).toBe("/res/v1/llm/context"); + expect(requestUrl.searchParams.get("freshness")).toBe("pw"); + }); + + it("passes bounded date ranges to Brave llm-context endpoint", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch(); + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { + apiKey: "BSA...", + brave: { mode: "llm-context" }, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + await tool.execute({ + query: "latest ai news", + date_after: "2025-01-01", + date_before: "2025-01-31", + }); + + const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])); + expect(requestUrl.pathname).toBe("/res/v1/llm/context"); + expect(requestUrl.searchParams.get("freshness")).toBe("2025-01-01to2025-01-31"); + }); + + it("uses today as the end date for Brave llm-context date_after-only ranges", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch(); + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { + apiKey: "BSA...", + brave: { mode: "llm-context" }, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + await tool.execute({ query: "latest ai news", date_after: "2025-01-01" }); + + const today = new Date().toISOString().slice(0, 10); + const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])); + expect(requestUrl.pathname).toBe("/res/v1/llm/context"); + expect(requestUrl.searchParams.get("freshness")).toBe(`2025-01-01to${today}`); + }); + + it("rejects future Brave llm-context date_after-only ranges before fetch", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch(); + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { + apiKey: "BSA...", + brave: { mode: "llm-context" }, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const result = await tool.execute({ + query: "latest ai news", + date_after: "2999-01-01", + }); + + expect(result).toMatchObject({ + error: "invalid_date_range", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects Brave llm-context date_before-only ranges before fetch", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch(); + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { + apiKey: "BSA...", + brave: { mode: "llm-context" }, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const result = await tool.execute({ + query: "latest ai news", + date_before: "2025-01-31", + }); + + expect(result).toMatchObject({ + error: "unsupported_date_filter", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("falls back unsupported country values before calling Brave", async () => { vi.stubEnv("BRAVE_API_KEY", "test-key"); const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {