diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index d08ae1d153e8..5a5cc132e930 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -7,7 +7,9 @@ import { checksum } from "@opencode-ai/core/util/encode" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { RuntimeFlags } from "@/effect/runtime-flags" -export const Parameters = Schema.Struct({ +const PARALLEL_ADDITIONAL_QUERIES_MAX = 2 + +const baseFields = { query: Schema.String.annotate({ description: "Websearch query" }), numResults: Schema.optional(Schema.Number).annotate({ description: "Number of search results to return (default: 8)", @@ -22,6 +24,42 @@ export const Parameters = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number).annotate({ description: "Maximum characters for context string optimized for LLMs (default: 10000)", }), +} + +const objectiveField = Schema.String.annotate({ + description: + "A natural-language description of what you're trying to accomplish (e.g. 'Compare Q1 2026 earnings and margin performance for Tesla, Ford, and GM'). Must include the key entities or topics being researched. The search engine uses this to rank results for reasoning utility rather than human engagement.", +}) + +const additionalQueriesField = Schema.optional( + Schema.Array(Schema.String).check(Schema.isMaxLength(PARALLEL_ADDITIONAL_QUERIES_MAX)), +).annotate({ + description: `Optional. Up to ${PARALLEL_ADDITIONAL_QUERIES_MAX} additional keyword search queries (3-6 words each) to run alongside \`query\` in a single batched call. Use for multi-topic questions or distinct angles. Must be diverse — vary entity names, synonyms, and angles. Each query must include the key entity or topic. NEVER write sentences, instructions, or use site: operators. Prefer batching here over chaining separate websearch calls.`, +}) + +// Model-facing schema when Parallel is in play. `query` carries a keyword-style +// primary query; `objective` carries the research intent; `additionalQueries` +// is optional and lets the model batch related lookups. +export const ParallelParameters = Schema.Struct({ + ...baseFields, + // Override `query` description to match Parallel's keyword-style expectation. + query: Schema.String.annotate({ + description: + "Primary keyword search query (3-6 words). Must include the key entity or topic. NEVER write a sentence here — use `objective` for natural-language intent.", + }), + objective: objectiveField, + additionalQueries: additionalQueriesField, +}) + +// Model-facing schema when Parallel is gated out — no extras at all. +export const BaseParameters = Schema.Struct(baseFields) + +// Execute-side type: extras may be present or absent depending on which +// model-facing schema was active for the session. +export const Parameters = Schema.Struct({ + ...baseFields, + objective: Schema.optional(objectiveField), + additionalQueries: additionalQueriesField, }) const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) @@ -36,6 +74,21 @@ export function selectWebSearchProvider(sessionID: string, flags = { exa: false, return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" } +/** + * Whether Parallel-only schema fields (`objective`, `additionalQueries`) should + * be exposed to the model. Strict: only when Parallel is the resolved provider + * via env override or runtime flag. The 50/50 rollout fallback hides them so + * the model isn't asked for inputs that would be dropped on Exa sessions. + */ +export function shouldExposeParallelExtras( + flags: { exa: boolean; parallel: boolean }, + override = process.env.OPENCODE_WEBSEARCH_PROVIDER, +) { + if (override === "parallel") return true + if (override === "exa") return false + return flags.parallel +} + export function webSearchProviderLabel(provider: unknown) { if (provider === "parallel") return "Parallel Web Search" if (provider === "exa") return "Exa Web Search" @@ -70,8 +123,8 @@ function callProvider( "web_search", McpWebSearch.ParallelSearchArgs, { - objective: params.query, - search_queries: [params.query], + objective: params.objective ?? params.query, + search_queries: [params.query, ...(params.additionalQueries ?? [])], session_id: ctx.sessionID, model_name: webSearchModelName(ctx.extra), }, @@ -102,11 +155,25 @@ export const WebSearchTool = Tool.define( const http = yield* HttpClient.HttpClient const flags = yield* RuntimeFlags.Service + const exposeParallelExtras = shouldExposeParallelExtras({ + exa: flags.enableExa, + parallel: flags.enableParallel, + }) + + const parallelGuidance = [ + "", + "PREFER OVER REPEATED KEYWORD SEARCHES — one call covers the ground of 2-3 traditional queries with better relevance. Returns LLM-optimized excerpts (pre-compressed, citation-aware).", + "", + "Required: `objective` — a natural-language description of what you're trying to accomplish. Must name the key entities or topics.", + `Optional: \`additionalQueries\` — up to ${PARALLEL_ADDITIONAL_QUERIES_MAX} extra diverse keyword queries (3-6 words each) to run alongside \`query\` in a single batched call. Use this for multi-topic questions or distinct angles instead of chaining separate websearch calls.`, + ].join("\n") + return { get description() { - return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) + const base = DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) + return exposeParallelExtras ? `${base}\n${parallelGuidance}` : base }, - parameters: Parameters, + parameters: exposeParallelExtras ? ParallelParameters : BaseParameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const provider = selectWebSearchProvider(ctx.sessionID, { diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 1be32979ddfe..b71b8ce0c8d5 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -428,7 +428,7 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` } `; -exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` +exports[`tool parameters JSON Schema (wire shape) websearch (base, Exa-only mode) 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -469,6 +469,60 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` } `; +exports[`tool parameters JSON Schema (wire shape) websearch (parallel mode) 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "additionalQueries": { + "description": "Optional. Up to 2 additional keyword search queries (3-6 words each) to run alongside \`query\` in a single batched call. Use for multi-topic questions or distinct angles. Must be diverse — vary entity names, synonyms, and angles. Each query must include the key entity or topic. NEVER write sentences, instructions, or use site: operators. Prefer batching here over chaining separate websearch calls.", + "items": { + "type": "string", + }, + "maxItems": 2, + "type": "array", + }, + "contextMaxCharacters": { + "description": "Maximum characters for context string optimized for LLMs (default: 10000)", + "type": "number", + }, + "livecrawl": { + "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + "enum": [ + "fallback", + "preferred", + ], + "type": "string", + }, + "numResults": { + "description": "Number of search results to return (default: 8)", + "type": "number", + }, + "objective": { + "description": "A natural-language description of what you're trying to accomplish (e.g. 'Compare Q1 2026 earnings and margin performance for Tesla, Ford, and GM'). Must include the key entities or topics being researched. The search engine uses this to rank results for reasoning utility rather than human engagement.", + "type": "string", + }, + "query": { + "description": "Primary keyword search query (3-6 words). Must include the key entity or topic. NEVER write a sentence here — use \`objective\` for natural-language intent.", + "type": "string", + }, + "type": { + "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + "enum": [ + "auto", + "fast", + "deep", + ], + "type": "string", + }, + }, + "required": [ + "query", + "objective", + ], + "type": "object", +} +`; + exports[`tool parameters JSON Schema (wire shape) write 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 3a124be81b0c..39f6451f84e9 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -23,7 +23,11 @@ import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" import { Parameters as WebFetch } from "../../src/tool/webfetch" -import { Parameters as WebSearch } from "../../src/tool/websearch" +import { + Parameters as WebSearch, + BaseParameters as WebSearchBase, + ParallelParameters as WebSearchParallel, +} from "../../src/tool/websearch" import { Parameters as Write } from "../../src/tool/write" const parse = >(schema: S, input: unknown): S["Type"] => @@ -50,7 +54,10 @@ describe("tool parameters", () => { test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot()) test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot()) test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot()) - test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot()) + // The execute-side `Parameters` is an internal convenience type — the model only + // ever sees BaseParameters or ParallelParameters depending on provider gating. + test("websearch (base, Exa-only mode)", () => expect(toJsonSchema(WebSearchBase)).toMatchSnapshot()) + test("websearch (parallel mode)", () => expect(toJsonSchema(WebSearchParallel)).toMatchSnapshot()) test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) test("inlines named child schemas for provider compatibility", () => { @@ -235,10 +242,6 @@ describe("tool parameters", () => { const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" }) expect(parsed.subagent_type).toBe("general") }) - test("accepts optional background flag", () => { - const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true }) - expect(parsed.background).toBe(true) - }) test("rejects missing prompt", () => { expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index b8edc2dc2fd4..ef52fe616864 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { parseResponse } from "../../src/tool/mcp-websearch" -import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" +import { + Parameters, + ParallelParameters, + selectWebSearchProvider, + shouldExposeParallelExtras, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" import { ProviderID } from "../../src/provider/schema" import { webSearchEnabled } from "../../src/tool/registry" import { it } from "../lib/effect" @@ -61,6 +68,81 @@ describe("websearch provider", () => { }) }) +describe("websearch Parallel-only schema gating", () => { + test("exposes Parallel extras when the env override forces Parallel", () => { + expect(shouldExposeParallelExtras({ exa: false, parallel: false }, "parallel")).toBe(true) + }) + + test("hides Parallel extras when the env override forces Exa", () => { + expect(shouldExposeParallelExtras({ exa: false, parallel: true }, "exa")).toBe(false) + }) + + test("exposes Parallel extras when the Parallel flag is on and no override", () => { + expect(shouldExposeParallelExtras({ exa: false, parallel: true }, undefined)).toBe(true) + }) + + test("hides Parallel extras when only the Exa flag is on", () => { + expect(shouldExposeParallelExtras({ exa: true, parallel: false }, undefined)).toBe(false) + }) + + test("hides Parallel extras in the 50/50 rollout fallback", () => { + expect(shouldExposeParallelExtras({ exa: false, parallel: false }, undefined)).toBe(false) + }) +}) + +describe("websearch model-facing schema (Parallel exposed)", () => { + const decode = Schema.decodeUnknownSync(ParallelParameters) + const validInput = { + query: "tesla q1 earnings", + objective: "Compare Q1 2026 earnings for major US automakers", + additionalQueries: ["ford q1 2026 earnings", "gm q1 2026 earnings"], + } + + test("accepts a fully populated request", () => { + expect(() => decode(validInput)).not.toThrow() + }) + + test("accepts a single additionalQueries entry", () => { + expect(() => decode({ ...validInput, additionalQueries: ["ford q1 2026 earnings"] })).not.toThrow() + }) + + test("accepts an empty additionalQueries array", () => { + expect(() => decode({ ...validInput, additionalQueries: [] })).not.toThrow() + }) + + test("accepts a request with no additionalQueries (single-topic search)", () => { + const { additionalQueries: _omit, ...rest } = validInput + expect(() => decode(rest)).not.toThrow() + }) + + test("rejects more than 2 additionalQueries", () => { + expect(() => decode({ ...validInput, additionalQueries: ["a", "b", "c"] })).toThrow() + }) + + test("rejects a request missing objective", () => { + const { objective: _omit, ...rest } = validInput + expect(() => decode(rest)).toThrow() + }) +}) + +describe("websearch execute-side schema (extras tolerated as optional)", () => { + const decode = Schema.decodeUnknownSync(Parameters) + + test("accepts a call with only query (Exa shape)", () => { + expect(() => decode({ query: "tesla q1 earnings" })).not.toThrow() + }) + + test("accepts a fully populated Parallel call", () => { + expect(() => + decode({ + query: "tesla q1 earnings", + objective: "Compare Q1 2026 earnings", + additionalQueries: ["ford q1 2026 earnings"], + }), + ).not.toThrow() + }) +}) + describe("websearch MCP response parser", () => { const payload = JSON.stringify({ jsonrpc: "2.0",