fix(gemini): pass search time filters

This commit is contained in:
Peter Steinberger
2026-05-02 05:00:31 +01:00
parent e93ff249b0
commit 20333bd58d
6 changed files with 242 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@@ -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<GeminiFreshness, number> = {
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<string, unknown>,
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<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
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,

View File

@@ -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<string, unknown>;

View File

@@ -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" }),