fix(web-search): support Brave llm-context date filters

This commit is contained in:
Peter Steinberger
2026-05-02 04:39:26 +01:00
parent 5c33564eb8
commit 4397be1a24
5 changed files with 207 additions and 31 deletions

View File

@@ -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.

View File

@@ -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`).

View File

@@ -296,7 +296,8 @@ show the `x_search` prompt.
<Warning>
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.

View File

@@ -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,

View File

@@ -10,6 +10,28 @@ const braveManifest = JSON.parse(
configSchema?: Record<string, unknown>;
};
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) => {