From 22f429235e2a5289ca76128d0efcae93704fe3fe Mon Sep 17 00:00:00 2001 From: Sam Biggins Date: Wed, 15 Apr 2026 06:12:07 -0700 Subject: [PATCH] fix(web-search): decoupled Tavily topic from recency filter Removed unconditional topic:news coupling in buildRequestBody that scoped Tavily index to news publications whenever recency was set. Technical queries with --recency now search the general index filtered by time only. Tightened SearchParams.recency contract in base.ts: providers MUST interpret recency as a pure time filter and MUST NOT change topic scope as a side effect. --- packages/coding-agent/CHANGELOG.md | 12 +- .../src/web/search/providers/base.ts | 13 ++ .../src/web/search/providers/tavily.ts | 16 ++- .../test/tools/web-search-tavily.test.ts | 3 +- .../test/web/search/tavily.test.ts | 124 ++++++++++++++++++ 5 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/test/web/search/tavily.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 37bdb7d11..1e88ec909 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed + +- Fixed Tavily web search silently returning off-topic news articles when `--recency` was set. The provider was unconditionally coupling `topic: "news"` to recency, which scoped Tavily's index to news publications and excluded documentation, release notes, GitHub, and all non-news technical content. Technical queries with `--recency` now return the correct corpus. + +### Changed + +- Tightened the contract for `SearchParams.recency` in `web/search/providers/base.ts`: providers MUST interpret recency as a pure time filter and MUST NOT use it as an implicit signal to change topic scope, content domain, or ranking strategy. + ## [14.1.3] - 2026-04-17 ### Breaking Changes @@ -24,7 +32,6 @@ - Fixed task calls in `schema-free` and `independent` modes to return clear mode-specific errors when disallowed `context` or `schema` inputs are provided - Fixed newly generated session IDs to use UUIDv7 for new, forked, and branched sessions while preserving resumed session IDs - ## [14.1.1] - 2026-04-14 ### Breaking Changes @@ -206,7 +213,6 @@ - Fixed typo in system prompt: 'backwards compatibiltity' → 'backwards compatibility' - ## [14.0.3] - 2026-04-09 ### Fixed @@ -7105,4 +7111,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/web/search/providers/base.ts b/packages/coding-agent/src/web/search/providers/base.ts index e09f13505..c98178a6b 100644 --- a/packages/coding-agent/src/web/search/providers/base.ts +++ b/packages/coding-agent/src/web/search/providers/base.ts @@ -4,6 +4,19 @@ import type { SearchProviderId, SearchResponse } from "../types"; export interface SearchParams { query: string; limit?: number; + /** + * Temporal filter narrowing results to the specified time window. + * + * Providers MUST interpret this as a pure time filter. Providers MUST NOT + * use recency as an implicit signal to change topic scope, content domain, + * or ranking strategy. If a provider API couples temporal filtering with + * other dimensions (e.g. Tavily's `topic=news`), the provider implementation + * is responsible for decoupling them before calling the upstream API. + * + * Providers that do not support temporal filtering MUST ignore this field + * silently; they MUST NOT approximate it by rewriting the query or altering + * any other request parameter. + */ recency?: "day" | "week" | "month" | "year"; systemPrompt: string; signal?: AbortSignal; diff --git a/packages/coding-agent/src/web/search/providers/tavily.ts b/packages/coding-agent/src/web/search/providers/tavily.ts index a079651f5..df13a82a8 100644 --- a/packages/coding-agent/src/web/search/providers/tavily.ts +++ b/packages/coding-agent/src/web/search/providers/tavily.ts @@ -63,17 +63,25 @@ export async function findApiKey(): Promise { return findCredential(getEnvApiKey("tavily"), "tavily"); } -function buildRequestBody(params: TavilySearchParams): Record { +/** Exported for testing. Builds the Tavily request body from unified params. */ +export function buildRequestBody(params: TavilySearchParams): Record { const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); - return { + // Tavily's `topic` (general/news/finance) and `time_range` are orthogonal + // dimensions in the upstream API. Recency is a temporal filter only; it must + // not narrow the index to news-only, which would break technical queries + // (release notes, docs, GitHub) whenever a user sets --recency. Always use + // the default "general" topic and only send `time_range` when recency is set. + const body: Record = { query: params.query, search_depth: "basic", - topic: params.recency ? "news" : "general", - time_range: params.recency, max_results: numResults, include_answer: "advanced", include_raw_content: false, }; + if (params.recency) { + body.time_range = params.recency; + } + return body; } async function callTavilySearch(apiKey: string, params: TavilySearchParams): Promise { diff --git a/packages/coding-agent/test/tools/web-search-tavily.test.ts b/packages/coding-agent/test/tools/web-search-tavily.test.ts index 7f63f5a43..c531e9357 100644 --- a/packages/coding-agent/test/tools/web-search-tavily.test.ts +++ b/packages/coding-agent/test/tools/web-search-tavily.test.ts @@ -49,14 +49,15 @@ describe("Tavily web search provider", () => { }); const response = await searchTavily({ query: "latest ai news", num_results: 2, recency: "week" }); + // Recency must not couple to topic — topic should be absent (Tavily defaults to general) expect(requestBody).toMatchObject({ query: "latest ai news", max_results: 2, time_range: "week", - topic: "news", include_answer: "advanced", include_raw_content: false, }); + expect(requestBody).not.toHaveProperty("topic"); expect(response).toMatchObject({ provider: "tavily", answer: "Synthesized Tavily answer", diff --git a/packages/coding-agent/test/web/search/tavily.test.ts b/packages/coding-agent/test/web/search/tavily.test.ts new file mode 100644 index 000000000..feafb7c3b --- /dev/null +++ b/packages/coding-agent/test/web/search/tavily.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { + buildRequestBody, + searchTavily, + type TavilySearchParams, +} from "@oh-my-pi/pi-coding-agent/web/search/providers/tavily"; +import { hookFetch } from "@oh-my-pi/pi-utils"; + +describe("Tavily buildRequestBody", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("omits topic entirely so Tavily uses its default general index", () => { + const body = buildRequestBody({ query: "Bun 1.3 release notes" }); + expect(body).not.toHaveProperty("topic"); + }); + + it("does not send time_range when recency is unset", () => { + const body = buildRequestBody({ query: "Bun 1.3 release notes" }); + expect(body).not.toHaveProperty("time_range"); + }); + + it("sends time_range when recency is set, without switching topic to news", () => { + const body = buildRequestBody({ + query: "Bun 1.3 release notes", + recency: "week", + }); + expect(body.time_range).toBe("week"); + expect(body).not.toHaveProperty("topic"); + }); + + it.each(["day", "week", "month", "year"] as const)("passes %s through as time_range verbatim", recency => { + const body = buildRequestBody({ query: "q", recency }); + expect(body.time_range).toBe(recency); + expect(body).not.toHaveProperty("topic"); + }); + + it("always includes query, max_results, search_depth, and include_answer", () => { + const body = buildRequestBody({ query: "q", num_results: 7 }); + expect(body.query).toBe("q"); + expect(body.max_results).toBe(7); + expect(body.search_depth).toBe("basic"); + expect(body.include_answer).toBe("advanced"); + expect(body.include_raw_content).toBe(false); + }); +}); + +describe("Tavily searchTavily request shape (integration)", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.TAVILY_API_KEY; + }); + + it("does not send topic=news to the upstream API when recency is set", async () => { + process.env.TAVILY_API_KEY = "test-key"; + + let capturedBody: Record | undefined; + using _hook = hookFetch(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://api.tavily.com/search") { + capturedBody = JSON.parse(init?.body as string); + return new Response( + JSON.stringify({ + answer: "test answer", + results: [ + { + title: "Bun v1.3.12", + url: "https://bun.com/blog/bun-v1.3.12", + content: "release notes", + published_date: "2026-04-09", + }, + ], + request_id: "req-123", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not mocked", { status: 500 }); + }); + + const params: TavilySearchParams = { + query: "Bun runtime latest release notes", + recency: "week", + }; + const response = await searchTavily(params); + + expect(capturedBody).toBeDefined(); + // The core regression: recency must not coerce topic to news. Topic should + // be absent entirely (Tavily defaults to "general"). + expect(capturedBody).not.toHaveProperty("topic"); + expect(capturedBody?.time_range).toBe("week"); + expect(capturedBody?.query).toBe("Bun runtime latest release notes"); + + // And the response should still be parsed correctly end-to-end. + expect(response.provider).toBe("tavily"); + expect(response.answer).toBe("test answer"); + expect(response.sources).toHaveLength(1); + expect(response.sources[0]?.url).toBe("https://bun.com/blog/bun-v1.3.12"); + }); + + it("omits time_range entirely when recency is not provided", async () => { + process.env.TAVILY_API_KEY = "test-key"; + + let capturedBody: Record | undefined; + using _hook = hookFetch(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://api.tavily.com/search") { + capturedBody = JSON.parse(init?.body as string); + return new Response(JSON.stringify({ answer: "", results: [], request_id: "req-0" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not mocked", { status: 500 }); + }); + + await searchTavily({ query: "bun sqlite" }); + + expect(capturedBody).toBeDefined(); + expect(capturedBody).not.toHaveProperty("topic"); + expect(capturedBody).not.toHaveProperty("time_range"); + }); +});