feat(websearch): add parallel provider rollout (#26227)
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
|
||||
const URL = process.env.EXA_API_KEY
|
||||
export const EXA_URL = process.env.EXA_API_KEY
|
||||
? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}`
|
||||
: "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
@@ -18,11 +19,23 @@ const McpResult = Schema.Struct({
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
const data = yield* decode(trimmed)
|
||||
return data.result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* decode(line.substring(6))
|
||||
if (data.result.content[0]?.text) return data.result.content[0].text
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
@@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
export const ParallelSearchArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.optional(Schema.String),
|
||||
model_name: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
@@ -48,14 +68,17 @@ const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
timeout: Duration.Input,
|
||||
headers?: Record<string, string>,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(URL).pipe(
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers ?? {}),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
@@ -69,5 +92,5 @@ export const call = <F extends Schema.Struct.Fields>(
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
|
||||
)
|
||||
const body = yield* response.text
|
||||
return yield* parseSse(body)
|
||||
return yield* parseResponse(body)
|
||||
})
|
||||
@@ -49,6 +49,13 @@ import { Permission } from "@/permission"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
export function webSearchEnabled(
|
||||
providerID: ProviderID,
|
||||
flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL },
|
||||
) {
|
||||
return providerID === ProviderID.opencode || flags.exa || flags.parallel
|
||||
}
|
||||
|
||||
type TaskDef = Tool.InferDef<typeof TaskTool>
|
||||
type ReadDef = Tool.InferDef<typeof ReadTool>
|
||||
|
||||
@@ -284,7 +291,7 @@ export const layer: Layer.Layer<
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === WebSearchTool.id) {
|
||||
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
return webSearchEnabled(input.providerID)
|
||||
}
|
||||
|
||||
const usePatch =
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import * as McpWebSearch from "./mcp-websearch"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
@@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"])
|
||||
export type WebSearchProvider = Schema.Schema.Type<typeof WebSearchProviderSchema>
|
||||
|
||||
export function selectWebSearchProvider(
|
||||
sessionID: string,
|
||||
flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL },
|
||||
): WebSearchProvider {
|
||||
const override = process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
if (override === "exa" || override === "parallel") return override
|
||||
if (flags.parallel) return "parallel"
|
||||
if (flags.exa) return "exa"
|
||||
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
export function webSearchModelName(extra: Tool.Context["extra"]) {
|
||||
const model = extra?.model
|
||||
if (!model || typeof model !== "object") return undefined
|
||||
const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined
|
||||
const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined
|
||||
const id = "id" in model && typeof model.id === "string" ? model.id : undefined
|
||||
return (apiID ?? id)?.slice(0, 100)
|
||||
}
|
||||
|
||||
function parallelAuthHeaders() {
|
||||
const headers = { "User-Agent": `opencode/${InstallationVersion}` }
|
||||
if (!process.env.PARALLEL_API_KEY) return headers
|
||||
return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` }
|
||||
}
|
||||
|
||||
function callProvider(
|
||||
http: HttpClient.HttpClient,
|
||||
provider: WebSearchProvider,
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
if (provider === "parallel") {
|
||||
return McpWebSearch.call(
|
||||
http,
|
||||
McpWebSearch.PARALLEL_URL,
|
||||
"web_search",
|
||||
McpWebSearch.ParallelSearchArgs,
|
||||
{
|
||||
objective: params.query,
|
||||
search_queries: [params.query],
|
||||
session_id: ctx.sessionID,
|
||||
model_name: webSearchModelName(ctx.extra),
|
||||
},
|
||||
"25 seconds",
|
||||
parallelAuthHeaders(),
|
||||
)
|
||||
}
|
||||
|
||||
return McpWebSearch.call(
|
||||
http,
|
||||
McpWebSearch.EXA_URL,
|
||||
"web_search_exa",
|
||||
McpWebSearch.SearchArgs,
|
||||
{
|
||||
query: params.query,
|
||||
type: params.type || "auto",
|
||||
numResults: params.numResults || 8,
|
||||
livecrawl: params.livecrawl || "fallback",
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
"25 seconds",
|
||||
)
|
||||
}
|
||||
|
||||
export const WebSearchTool = Tool.define(
|
||||
"websearch",
|
||||
Effect.gen(function* () {
|
||||
@@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define(
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const provider = selectWebSearchProvider(ctx.sessionID)
|
||||
const title = webSearchProviderLabel(provider)
|
||||
yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } })
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "websearch",
|
||||
patterns: [params.query],
|
||||
@@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define(
|
||||
livecrawl: params.livecrawl,
|
||||
type: params.type,
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
provider,
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
"web_search_exa",
|
||||
McpExa.SearchArgs,
|
||||
{
|
||||
query: params.query,
|
||||
type: params.type || "auto",
|
||||
numResults: params.numResults || 8,
|
||||
livecrawl: params.livecrawl || "fallback",
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
"25 seconds",
|
||||
)
|
||||
const result = yield* callProvider(http, provider, params, ctx)
|
||||
|
||||
return {
|
||||
output: result ?? "No search results found. Please try a different query.",
|
||||
title: `Web search: ${params.query}`,
|
||||
metadata: {},
|
||||
title: `${title}: ${params.query}`,
|
||||
metadata: { provider },
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs
|
||||
- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- Supports configurable result counts and returns the content from the most relevant websites
|
||||
- Use this tool for accessing information beyond knowledge cutoff
|
||||
- Searches are performed automatically within a single API call
|
||||
|
||||
Usage notes:
|
||||
- Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
|
||||
- Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
|
||||
- Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
|
||||
- Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
|
||||
- Configurable context length for optimal LLM integration
|
||||
- Domain filtering and advanced search options available
|
||||
|
||||
|
||||
Reference in New Issue
Block a user