mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(gemini): pass search time filters
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user