From 20333bd58dc9b8d607c2c5a6b80c2fe5bb4c7c33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:00:31 +0100 Subject: [PATCH] fix(gemini): pass search time filters --- CHANGELOG.md | 1 + docs/tools/gemini-search.md | 8 +- docs/tools/web.md | 3 +- .../src/gemini-web-search-provider.runtime.ts | 123 +++++++++++++++++- .../google/src/gemini-web-search-provider.ts | 15 ++- extensions/google/web-search-provider.test.ts | 101 ++++++++++++++ 6 files changed, 242 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fab0c834e4d..cf5cdd23d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - 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/Gemini: pass `freshness` and `date_after`/`date_before` filters through Google Search grounding time ranges. Fixes #66498. Thanks @ismael-81. - Web search/DuckDuckGo: include the keyless DuckDuckGo provider in the web search setup wizard. Fixes #65862 and supersedes #65940. Thanks @Jah-yee. - 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: point Brave provider metadata at the canonical `/tools/brave-search` docs page and make the legacy `/brave-search` docs page a redirect stub. Fixes #65870 and supersedes #65892. Thanks @Magicray1217 and @Jah-yee. diff --git a/docs/tools/gemini-search.md b/docs/tools/gemini-search.md index b90ad17851a..e5e4c11c154 100644 --- a/docs/tools/gemini-search.md +++ b/docs/tools/gemini-search.md @@ -75,14 +75,16 @@ URLs. ## Supported parameters -Gemini search supports `query`. +Gemini search supports `query`, `freshness`, `date_after`, and `date_before`. `count` is accepted for shared `web_search` compatibility, but Gemini grounding still returns one synthesized answer with citations rather than an N-result list. -Provider-specific filters like `country`, `language`, `freshness`, and -`domain_filter` are not supported. +`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts +`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit +`date_after`/`date_before` range, into Gemini Google Search grounding's +`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported. ## Model selection diff --git a/docs/tools/web.md b/docs/tools/web.md index 0178b824835..746d7748909 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -300,7 +300,8 @@ show the `x_search` prompt. 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. + grounded answer shape. Gemini supports `freshness`, `date_after`, and + `date_before` by converting them to Google Search grounding time ranges. Perplexity behaves the same way when you use the Sonar/OpenRouter compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` / `model` or `OPENROUTER_API_KEY`). diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index 21203f2a185..b7b6516f06a 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -6,6 +6,8 @@ import { buildSearchCacheKey, buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + normalizeFreshness, + parseIsoDateRange, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -27,6 +29,13 @@ import { type GeminiConfig, } from "./gemini-web-search-provider.shared.js"; +type GeminiFreshness = "day" | "week" | "month" | "year"; + +type GeminiTimeRangeFilter = { + startTime: string; + endTime: string; +}; + type GeminiGroundingResponse = { candidates?: Array<{ content?: { @@ -50,6 +59,99 @@ type GeminiGroundingResponse = { }; }; +const GEMINI_FRESHNESS_DAYS: Record = { + day: 1, + week: 7, + month: 30, + year: 365, +}; + +function isoDateStart(value: string): string { + return `${value}T00:00:00Z`; +} + +function isoDateExclusiveEnd(value: string): string { + const end = new Date(`${value}T00:00:00Z`); + end.setUTCDate(end.getUTCDate() + 1); + return end.toISOString(); +} + +function freshnessStartTime(freshness: GeminiFreshness, now: Date): string { + const start = new Date(now.getTime()); + start.setUTCDate(start.getUTCDate() - GEMINI_FRESHNESS_DAYS[freshness]); + return start.toISOString(); +} + +function resolveGeminiTimeRangeFilter( + args: Record, + now = new Date(), +): + | { timeRangeFilter?: GeminiTimeRangeFilter } + | { + error: + | "invalid_freshness" + | "invalid_date" + | "invalid_date_range" + | "conflicting_time_filters"; + message: string; + docs: string; + } { + const rawFreshness = readStringParam(args, "freshness"); + const freshness = rawFreshness + ? (normalizeFreshness(rawFreshness, "perplexity") as GeminiFreshness | undefined) + : undefined; + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: "freshness must be day, week, month, year, or the shortcuts pd, pw, pm, py.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const rawDateAfter = readStringParam(args, "date_after"); + const rawDateBefore = readStringParam(args, "date_before"); + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be before date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; + } + + if (freshness) { + return { + timeRangeFilter: { + startTime: freshnessStartTime(freshness, now), + endTime: now.toISOString(), + }, + }; + } + + const { dateAfter, dateBefore } = parsedDateRange; + if (!dateAfter && !dateBefore) { + return {}; + } + + return { + timeRangeFilter: { + startTime: dateAfter ? isoDateStart(dateAfter) : "1970-01-01T00:00:00Z", + endTime: dateBefore ? isoDateExclusiveEnd(dateBefore) : now.toISOString(), + }, + }; +} + export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined { return ( readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? @@ -63,8 +165,11 @@ async function runGeminiSearch(params: { baseUrl: string; model: string; timeoutSeconds: number; + timeRangeFilter?: GeminiTimeRangeFilter; }): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { const endpoint = `${params.baseUrl}/models/${params.model}:generateContent`; + const googleSearch = + params.timeRangeFilter === undefined ? {} : { timeRangeFilter: params.timeRangeFilter }; return withTrustedWebSearchEndpoint( { @@ -78,7 +183,7 @@ async function runGeminiSearch(params: { }, body: JSON.stringify({ contents: [{ parts: [{ text: params.query }] }], - tools: [{ google_search: {} }], + tools: [{ google_search: googleSearch }], }), }, }, @@ -140,11 +245,22 @@ export async function executeGeminiSearch( args: Record, searchConfig?: SearchConfigRecord, ): Promise> { - const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "gemini"); + const unsupportedResponse = buildUnsupportedSearchFilterResponse( + { + country: args.country, + language: args.language, + }, + "gemini", + ); if (unsupportedResponse) { return unsupportedResponse; } + const timeRange = resolveGeminiTimeRangeFilter(args); + if ("error" in timeRange) { + return timeRange; + } + const geminiConfig = resolveGeminiConfig(searchConfig); const apiKey = resolveGeminiRuntimeApiKey(geminiConfig); if (!apiKey) { @@ -167,6 +283,8 @@ export async function executeGeminiSearch( resolveSearchCount(count, DEFAULT_SEARCH_COUNT), baseUrl, model, + timeRange.timeRangeFilter?.startTime, + timeRange.timeRangeFilter?.endTime, ]); const cached = readCachedSearchPayload(cacheKey); if (cached) { @@ -180,6 +298,7 @@ export async function executeGeminiSearch( baseUrl, model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + timeRangeFilter: timeRange.timeRangeFilter, }); const payload = { query, diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 8d6609e0cae..a028acb7eab 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -34,9 +34,18 @@ const GEMINI_TOOL_PARAMETERS = { }, country: { type: "string", description: "Not supported by Gemini." }, language: { type: "string", description: "Not supported by Gemini." }, - freshness: { type: "string", description: "Not supported by Gemini." }, - date_after: { type: "string", description: "Not supported by Gemini." }, - date_before: { type: "string", description: "Not supported by Gemini." }, + freshness: { + type: "string", + description: "Limit Google Search grounding to recent results: day, week, month, or year.", + }, + date_after: { + type: "string", + description: "Only ground with results published after this date (YYYY-MM-DD).", + }, + date_before: { + type: "string", + description: "Only ground with results published before this date (YYYY-MM-DD).", + }, }, required: ["query"], } satisfies Record; diff --git a/extensions/google/web-search-provider.test.ts b/extensions/google/web-search-provider.test.ts index 2b4fca86e79..7ce7493321e 100644 --- a/extensions/google/web-search-provider.test.ts +++ b/extensions/google/web-search-provider.test.ts @@ -25,6 +25,7 @@ function installGeminiFetch() { } afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -101,6 +102,106 @@ describe("google web search provider", () => { ); }); + it("passes freshness to Gemini Google Search grounding as a time range", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-15T12:00:00Z")); + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "latest ai news", freshness: "week" }); + + const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)) as { + tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>; + }; + expect(body.tools?.[0]?.google_search?.timeRangeFilter).toEqual({ + startTime: "2026-04-08T12:00:00.000Z", + endTime: "2026-04-15T12:00:00.000Z", + }); + }); + + it("passes date ranges to Gemini Google Search grounding", async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ + query: "OpenClaw release notes", + date_after: "2026-04-01", + date_before: "2026-04-30", + }); + + const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)) as { + tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>; + }; + expect(body.tools?.[0]?.google_search?.timeRangeFilter).toEqual({ + startTime: "2026-04-01T00:00:00Z", + endTime: "2026-05-01T00:00:00.000Z", + }); + }); + + it("returns validation errors for invalid Gemini time filters before fetch", async () => { + const mockFetch = installGeminiFetch(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await expect( + tool?.execute({ + query: "OpenClaw release notes", + freshness: "week", + date_after: "2026-04-01", + }), + ).resolves.toMatchObject({ + error: "conflicting_time_filters", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("normalizes Gemini shorthand base URLs", () => { expect( __testing.resolveGeminiBaseUrl({ baseUrl: "https://generativelanguage.googleapis.com" }),