diff --git a/.gitignore b/.gitignore index 55f905293cf..dc290f269a4 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ USER.md .agent/*.json !.agent/workflows/ local/ +package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b6103ce5aa8..b563a8dcae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise. - Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale. - Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42. +- Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic. - Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner. - Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c. - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. diff --git a/docs/tools/web.md b/docs/tools/web.md index c22bc1707eb..859e6144c51 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -175,7 +175,9 @@ Search the web using your configured provider. - `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region. - `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr") - `ui_lang` (optional): ISO language code for UI elements -- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`) +- `freshness` (optional): filter by discovery time + - Brave: `pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD` + - Perplexity: `pd`, `pw`, `pm`, `py` **Examples:** diff --git a/src/agents/tools/web-search.e2e.test.ts b/src/agents/tools/web-search.e2e.test.ts index ff421ef2ccc..e8896f908b4 100644 --- a/src/agents/tools/web-search.e2e.test.ts +++ b/src/agents/tools/web-search.e2e.test.ts @@ -31,6 +31,7 @@ const { isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, normalizeFreshness, + freshnessToPerplexityRecency, resolveGrokApiKey, resolveGrokModel, resolveGrokInlineCitations, @@ -128,6 +129,24 @@ describe("web_search freshness normalization", () => { }); }); +describe("freshnessToPerplexityRecency", () => { + it("maps Brave shortcuts to Perplexity recency values", () => { + expect(freshnessToPerplexityRecency("pd")).toBe("day"); + expect(freshnessToPerplexityRecency("pw")).toBe("week"); + expect(freshnessToPerplexityRecency("pm")).toBe("month"); + expect(freshnessToPerplexityRecency("py")).toBe("year"); + }); + + it("returns undefined for date ranges (not supported by Perplexity)", () => { + expect(freshnessToPerplexityRecency("2024-01-01to2024-01-31")).toBeUndefined(); + }); + + it("returns undefined for undefined/empty input", () => { + expect(freshnessToPerplexityRecency(undefined)).toBeUndefined(); + expect(freshnessToPerplexityRecency("")).toBeUndefined(); + }); +}); + describe("web_search grok config resolution", () => { it("uses config apiKey when provided", () => { expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 90a49da7378..f2e059f439c 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -64,7 +64,7 @@ const WebSearchSchema = Type.Object({ freshness: Type.Optional( Type.String({ description: - "Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.", + "Filter results by discovery time. Brave supports 'pd', 'pw', 'pm', 'py', and date range 'YYYY-MM-DDtoYYYY-MM-DD'. Perplexity supports 'pd', 'pw', 'pm', and 'py'.", }), ), }); @@ -403,6 +403,23 @@ function normalizeFreshness(value: string | undefined): string | undefined { return `${start}to${end}`; } +/** + * Map normalized freshness values (pd/pw/pm/py) to Perplexity's + * search_recency_filter values (day/week/month/year). + */ +function freshnessToPerplexityRecency(freshness: string | undefined): string | undefined { + if (!freshness) { + return undefined; + } + const map: Record = { + pd: "day", + pw: "week", + pm: "month", + py: "year", + }; + return map[freshness] ?? undefined; +} + function isValidIsoDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { return false; @@ -435,11 +452,27 @@ async function runPerplexitySearch(params: { baseUrl: string; model: string; timeoutSeconds: number; + freshness?: string; }): Promise<{ content: string; citations: string[] }> { const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); const endpoint = `${baseUrl}/chat/completions`; const model = resolvePerplexityRequestModel(baseUrl, params.model); + const body: Record = { + model, + messages: [ + { + role: "user", + content: params.query, + }, + ], + }; + + const recencyFilter = freshnessToPerplexityRecency(params.freshness); + if (recencyFilter) { + body.search_recency_filter = recencyFilter; + } + const res = await fetch(endpoint, { method: "POST", headers: { @@ -448,15 +481,7 @@ async function runPerplexitySearch(params: { "HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw Web Search", }, - body: JSON.stringify({ - model, - messages: [ - { - role: "user", - content: params.query, - }, - ], - }), + body: JSON.stringify(body), signal: withTimeout(undefined, params.timeoutSeconds * 1000), }); @@ -544,7 +569,7 @@ async function runWebSearch(params: { params.provider === "brave" ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}` : params.provider === "perplexity" - ? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}` + ? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.freshness || "default"}` : `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); @@ -561,6 +586,7 @@ async function runWebSearch(params: { baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, timeoutSeconds: params.timeoutSeconds, + freshness: params.freshness, }); const payload = { @@ -722,10 +748,10 @@ export function createWebSearchTool(options?: { const search_lang = readStringParam(params, "search_lang"); const ui_lang = readStringParam(params, "ui_lang"); const rawFreshness = readStringParam(params, "freshness"); - if (rawFreshness && provider !== "brave") { + if (rawFreshness && provider !== "brave" && provider !== "perplexity") { return jsonResult({ error: "unsupported_freshness", - message: "freshness is only supported by the Brave web_search provider.", + message: "freshness is only supported by the Brave and Perplexity web_search providers.", docs: "https://docs.openclaw.ai/tools/web", }); } @@ -769,6 +795,7 @@ export const __testing = { isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, normalizeFreshness, + freshnessToPerplexityRecency, resolveGrokApiKey, resolveGrokModel, resolveGrokInlineCitations, diff --git a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts index 4c62bcdb527..c95e328b75e 100644 --- a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts @@ -159,7 +159,7 @@ describe("web_search perplexity baseUrl defaults", () => { expect(body.model).toBe("sonar-pro"); }); - it("rejects freshness for Perplexity provider", async () => { + it("passes freshness to Perplexity provider as search_recency_filter", async () => { vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); const mockFetch = vi.fn(() => Promise.resolve({ @@ -174,10 +174,11 @@ describe("web_search perplexity baseUrl defaults", () => { config: { tools: { web: { search: { provider: "perplexity" } } } }, sandboxed: true, }); - const result = await tool?.execute?.(1, { query: "test", freshness: "pw" }); + await tool?.execute?.(1, { query: "perplexity-freshness-test", freshness: "pw" }); - expect(mockFetch).not.toHaveBeenCalled(); - expect(result?.details).toMatchObject({ error: "unsupported_freshness" }); + expect(mockFetch).toHaveBeenCalledOnce(); + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(body.search_recency_filter).toBe("week"); }); it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {