refactor: share web search time filters

This commit is contained in:
Vincent Koc
2026-05-30 07:24:46 +02:00
parent 72a2cc0acb
commit ceb179f84d
5 changed files with 266 additions and 176 deletions

View File

@@ -8,8 +8,7 @@ import {
DEFAULT_SEARCH_COUNT,
formatCliCommand,
MAX_SEARCH_COUNT,
normalizeFreshness,
parseIsoDateRange,
parseWebSearchTimeFilters,
readCachedSearchPayload,
readConfiguredSecretString,
readPositiveIntegerParam,
@@ -45,6 +44,7 @@ const BRAVE_SEARCH_ENDPOINT_PATH = "/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT_PATH = "/res/v1/llm/context";
const braveHttpLogger = createSubsystemLogger("brave/http");
type BraveEndpointMode = "selfHosted" | "strict";
type BraveSearchMode = "llm-context" | "web";
type BraveSearchResult = {
title?: string;
@@ -158,6 +158,91 @@ function missingBraveKeyPayload() {
};
}
function setBraveSearchUrlParams(
url: URL,
params: {
query: string;
country?: string;
search_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
allowDateBeforeOnly?: boolean;
},
): void {
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
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)}`,
);
} else if (params.allowDateBeforeOnly && params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
}
async function runBraveJsonRequest<T>(
params: {
baseUrl: string;
endpointPath: string;
endpointMode: BraveEndpointMode;
mode: BraveSearchMode;
apiKey: string;
timeoutSeconds: number;
diagnostics?: BraveHttpDiagnostics;
configureUrl: (url: URL) => void;
},
errorLabel: string,
): Promise<T> {
const url = buildBraveEndpointUrl({
baseUrl: params.baseUrl,
endpointPath: params.endpointPath,
});
params.configureUrl(url);
logBraveHttp(params.diagnostics, "request", {
mode: params.mode,
...describeBraveRequestUrl(url),
});
const startedAt = Date.now();
const withEndpoint =
params.endpointMode === "selfHosted"
? withSelfHostedWebSearchEndpoint
: withTrustedWebSearchEndpoint;
return withEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
logBraveHttp(params.diagnostics, "response", {
mode: params.mode,
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt,
});
await assertOkOrThrowProviderError(response, errorLabel);
return readProviderJsonResponse<T>(response, errorLabel);
},
);
}
async function runBraveLlmContextSearch(params: {
baseUrl: string;
endpointMode: BraveEndpointMode;
@@ -179,65 +264,22 @@ async function runBraveLlmContextSearch(params: {
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = buildBraveEndpointUrl({
baseUrl: params.baseUrl,
endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
});
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
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)}`,
);
}
logBraveHttp(params.diagnostics, "request", {
mode: "llm-context",
...describeBraveRequestUrl(url),
});
const startedAt = Date.now();
const withEndpoint =
params.endpointMode === "selfHosted"
? withSelfHostedWebSearchEndpoint
: withTrustedWebSearchEndpoint;
return withEndpoint(
const data = await runBraveJsonRequest<BraveLlmContextResponse>(
{
url: url.toString(),
baseUrl: params.baseUrl,
endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
mode: "llm-context",
endpointMode: params.endpointMode,
apiKey: params.apiKey,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
diagnostics: params.diagnostics,
configureUrl: (url) => {
setBraveSearchUrlParams(url, params);
},
},
async (response) => {
logBraveHttp(params.diagnostics, "response", {
mode: "llm-context",
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt,
});
await assertOkOrThrowProviderError(response, "Brave LLM Context API error");
const data = await readProviderJsonResponse<BraveLlmContextResponse>(
response,
"Brave LLM Context API error",
);
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
"Brave LLM Context API error",
);
return { results: mapBraveLlmContextResults(data), sources: data.sources };
}
async function runBraveWebSearch(params: {
@@ -255,83 +297,41 @@ async function runBraveWebSearch(params: {
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const url = buildBraveEndpointUrl({
baseUrl: params.baseUrl,
endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
});
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
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)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
logBraveHttp(params.diagnostics, "request", {
mode: "web",
...describeBraveRequestUrl(url),
});
const startedAt = Date.now();
const withEndpoint =
params.endpointMode === "selfHosted"
? withSelfHostedWebSearchEndpoint
: withTrustedWebSearchEndpoint;
return withEndpoint(
const data = await runBraveJsonRequest<BraveSearchResponse>(
{
url: url.toString(),
baseUrl: params.baseUrl,
endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
mode: "web",
endpointMode: params.endpointMode,
apiKey: params.apiKey,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
diagnostics: params.diagnostics,
configureUrl: (url) => {
setBraveSearchUrlParams(url, {
...params,
allowDateBeforeOnly: true,
});
url.searchParams.set("count", String(params.count));
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
},
},
async (response) => {
logBraveHttp(params.diagnostics, "response", {
mode: "web",
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt,
});
await assertOkOrThrowProviderError(response, "Brave Search API error");
const data = await readProviderJsonResponse<BraveSearchResponse>(
response,
"Brave Search API error",
);
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
"Brave Search API error",
);
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
}
export async function executeBraveSearch(
@@ -392,37 +392,23 @@ export async function executeBraveSearch(
}
const rawFreshness = readStringParam(args, "freshness");
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
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({
const parsedTimeFilters = parseWebSearchTimeFilters({
rawDateAfter,
rawDateBefore,
rawFreshness,
freshnessProvider: "brave",
invalidFreshnessMessage: "freshness must be day, week, month, or year.",
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 ("error" in parsedTimeFilters) {
return parsedTimeFilters;
}
const { dateAfter, dateBefore } = parsedDateRange;
const { freshness, dateAfter, dateBefore } = parsedTimeFilters;
if (braveMode === "llm-context") {
const today = new Date().toISOString().slice(0, 10);
if (dateAfter && !dateBefore && dateAfter > today) {

View File

@@ -8,8 +8,7 @@ import {
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
normalizeFreshness,
parseIsoDateRange,
parseWebSearchTimeFilters,
readCachedSearchPayload,
readConfiguredSecretString,
readPositiveIntegerParam,
@@ -114,39 +113,24 @@ function resolveGeminiTimeRangeFilter(
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({
const parsedTimeFilters = parseWebSearchTimeFilters({
rawDateAfter,
rawDateBefore,
rawFreshness,
freshnessProvider: "perplexity",
invalidFreshnessMessage:
"freshness must be day, week, month, year, or the shortcuts pd, pw, pm, py.",
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 ("error" in parsedTimeFilters) {
return parsedTimeFilters;
}
const { freshness, dateAfter, dateBefore } = parsedTimeFilters;
if (freshness) {
return {
timeRangeFilter: {
@@ -156,7 +140,6 @@ function resolveGeminiTimeRangeFilter(
};
}
const { dateAfter, dateBefore } = parsedDateRange;
if (!dateAfter && !dateBefore) {
return {};
}

View File

@@ -189,6 +189,11 @@ const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]);
export type WebSearchFreshnessProvider = "brave" | "perplexity";
export type WebSearchRecencyFreshness = "day" | "week" | "month" | "year";
export type ParsedWebSearchFreshness<Provider extends WebSearchFreshnessProvider> =
Provider extends "perplexity" ? WebSearchRecencyFreshness : string;
export const FRESHNESS_TO_RECENCY: Record<string, string> = {
pd: "day",
pw: "week",
@@ -289,7 +294,7 @@ export function parseIsoDateRange(params: {
export function normalizeFreshness(
value: string | undefined,
provider: "brave" | "perplexity",
provider: WebSearchFreshnessProvider,
): string | undefined {
if (!value) {
return undefined;
@@ -319,6 +324,74 @@ export function normalizeFreshness(
return undefined;
}
export function parseWebSearchTimeFilters<Provider extends WebSearchFreshnessProvider>(params: {
rawFreshness?: string;
rawDateAfter?: string;
rawDateBefore?: string;
freshnessProvider: Provider;
invalidFreshnessMessage: string;
invalidDateAfterMessage: string;
invalidDateBeforeMessage: string;
invalidDateRangeMessage: string;
conflictingTimeFiltersMessage?: string;
docs?: string;
}):
| {
freshness?: ParsedWebSearchFreshness<Provider>;
dateAfter?: string;
dateBefore?: string;
}
| {
error:
| "invalid_freshness"
| "invalid_date"
| "invalid_date_range"
| "conflicting_time_filters";
message: string;
docs: string;
} {
const docs = params.docs ?? "https://docs.openclaw.ai/tools/web";
const freshness = params.rawFreshness
? normalizeFreshness(params.rawFreshness, params.freshnessProvider)
: undefined;
if (params.rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: params.invalidFreshnessMessage,
docs,
};
}
if (params.rawFreshness && (params.rawDateAfter || params.rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
params.conflictingTimeFiltersMessage ??
"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,
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter: params.rawDateAfter,
rawDateBefore: params.rawDateBefore,
invalidDateAfterMessage: params.invalidDateAfterMessage,
invalidDateBeforeMessage: params.invalidDateBeforeMessage,
invalidDateRangeMessage: params.invalidDateRangeMessage,
docs,
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
return freshness
? {
freshness: freshness as ParsedWebSearchFreshness<Provider>,
...parsedDateRange,
}
: parsedDateRange;
}
export function readCachedSearchPayload(cacheKey: string): Record<string, unknown> | undefined {
const cached = readCache(SEARCH_CACHE, cacheKey);
return cached ? { ...cached.value, cached: true } : undefined;

View File

@@ -5,6 +5,7 @@ import {
isoToPerplexityDate,
normalizeToIsoDate,
normalizeFreshness,
parseWebSearchTimeFilters,
} from "./web-search-provider-common.js";
import { mergeScopedSearchConfig } from "./web-search-provider-config.js";
import { createWebSearchTool } from "./web-search.js";
@@ -88,6 +89,52 @@ describe("web_search date normalization", () => {
});
});
describe("web_search time filter parsing", () => {
const baseMessages = {
invalidFreshnessMessage: "bad freshness",
invalidDateAfterMessage: "bad after",
invalidDateBeforeMessage: "bad before",
invalidDateRangeMessage: "bad range",
};
it("normalizes freshness shortcuts for providers", () => {
expect(
parseWebSearchTimeFilters({
rawFreshness: "pd",
freshnessProvider: "perplexity",
...baseMessages,
}),
).toEqual({ freshness: "day" });
});
it("rejects conflicting freshness and date filters", () => {
expect(
parseWebSearchTimeFilters({
rawFreshness: "week",
rawDateAfter: "2026-01-01",
freshnessProvider: "brave",
...baseMessages,
}),
).toEqual({
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",
});
});
it("parses date bounds through the shared ISO range validator", () => {
expect(
parseWebSearchTimeFilters({
rawDateAfter: "2026-01-01",
rawDateBefore: "2026-01-31",
freshnessProvider: "brave",
...baseMessages,
}),
).toEqual({ dateAfter: "2026-01-01", dateBefore: "2026-01-31" });
});
});
describe("web_search unsupported filter response", () => {
it("returns undefined when no unsupported filter is set", () => {
expect(buildUnsupportedSearchFilterResponse({ query: "openclaw" }, "gemini")).toBeUndefined();

View File

@@ -26,6 +26,7 @@ export {
normalizeFreshness,
normalizeToIsoDate,
parseIsoDateRange,
parseWebSearchTimeFilters,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,