Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions packages/opencode/src/tool/websearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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"])
Expand All @@ -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"
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const provider = selectWebSearchProvider(ctx.sessionID, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 9 additions & 6 deletions packages/opencode/test/tool/parameters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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)
})
Expand Down
86 changes: 84 additions & 2 deletions packages/opencode/test/tool/websearch.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
Expand Down
Loading