feat(core): add pluggable web search (#35558)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Shoubhit Dash 2026-07-26 09:25:05 +05:30 committed by GitHub
commit efb629a33a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1700 additions and 587 deletions

View file

@ -1,6 +1,8 @@
export * as PluginHost from "./host"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { App } from "../app"
import { Effect, Schema, Stream } from "effect"
@ -23,6 +25,7 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
import { WebSearch } from "../websearch"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T>
@ -38,6 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearch.Service
const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
@ -247,79 +251,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id) => draft.remove(Integration.ID.make(id)),
method: {
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}
return {
...authorization,
callback: (code: string) =>
authorization.callback(code).pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}),
),
...(refresh
? {
refresh: (value: Credential.OAuth) =>
refresh(value).pipe(
Effect.map((next) =>
Credential.OAuth.make({
...next,
methodID: Integration.MethodID.make(next.methodID),
}),
),
),
}
: {}),
...(input.label ? { label: input.label } : {}),
})
return
}
if (input.method.type === "env") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
})
return
}
if (input.method.type === "command") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
})
return
}
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
})
},
update: (input) => draft.method.update(methodImplementation(input)),
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
@ -430,6 +362,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
},
},
websearch: {
providers: () => response(websearch.providers()),
query: (input) =>
response(
websearch.query({
query: input.query,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
},
})
}),
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
@ -449,3 +407,50 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
} satisfies Plugin.Context
})
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("authorize" in input) {
const refresh = input.refresh
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(Effect.map(credential)),
}
}
return {
...authorization,
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
}
}),
),
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
...(input.label ? { label: input.label } : {}),
}
}
if (input.method.type === "env") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
}
}
if (input.method.type === "command") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
}
}
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
}
}
function credential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}

View file

@ -13,6 +13,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { Form } from "../form"
@ -21,12 +22,14 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "@opencode-ai/util/npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
@ -50,6 +53,7 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { SystemPromptPlugin } from "./system-prompt"
@ -70,6 +74,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
@ -79,12 +84,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
@ -99,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
@ -108,12 +114,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
Context.make(WellKnown.Service, wellknown),
)
})
@ -132,6 +138,7 @@ const pre = [
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
@ -153,6 +160,7 @@ const post = [
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]

View file

@ -12,6 +12,7 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { DateTime, Effect, Scope, Stream } from "effect"
import { Tool } from "../tool/tool"
@ -197,6 +198,31 @@ export function fromPromise(plugin: Plugin) {
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
providers: (input) => run(host.websearch.providers(input)),
query: (input) =>
run(
host.websearch.query({
...input,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: () => run(host.websearch.reload()),
transform: (callback) =>
register(
host.websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
}),
default: draft.default,
})
}),
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
@ -270,6 +296,10 @@ export function fromPromise(plugin: Plugin) {
})
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
return Model.Ref.make({
id: Model.ID.make(input.id),

View file

@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
@ -34,7 +35,7 @@ import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ToolRegistry } from "../tool/registry"
import { WebSearchTool } from "../tool/websearch"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
@ -289,6 +290,7 @@ export const node = makeLocationNode({
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
@ -303,7 +305,7 @@ export const node = makeLocationNode({
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
WebSearchTool.configNode,
WebSearch.node,
WellKnown.node,
],
})

View file

@ -0,0 +1,82 @@
export * as WebSearchExa from "./exa"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
numResults: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
}),
),
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
integrationID: "exa",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "exa",
method: { type: "env", names: ["EXA_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "exa",
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("exa")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
const result = yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: McpInput, output: McpOutput },
{ query: input.query, numResults: 8 },
)
const content = result?.content.find((item) => item.text)
return content ? parseResults(content.text) : []
}),
})
})
}),
})
function parseResults(text: string) {
return text.split(/\n\n---\n\n/).flatMap((block) => {
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
if (!url) return []
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim()
const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined
const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim()
return [
{
url,
...(title && title !== "N/A" ? { title } : {}),
...(content ? { content } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
},
]
})
}

View file

@ -0,0 +1,4 @@
import { WebSearchExa } from "./exa"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const

View file

@ -0,0 +1,68 @@
export * as WebSearchMcp from "./mcp"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { collectBoundedResponseBody } from "../../tool/http-body"
export const MAX_RESPONSE_BYTES = 256 * 1024
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
const parse = (payload: string) => {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
return decode(trimmed).pipe(Effect.map((response) => response.result))
}
return Effect.gen(function* () {
const trimmed = body.trim()
const direct = trimmed ? yield* parse(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parse(line.substring(6))
if (data) return data
}
})
}
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({ name: Schema.String, arguments: schema.input }),
}),
)({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})

View file

@ -0,0 +1,103 @@
export * as WebSearchParallel from "./parallel"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
const McpInput = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
})
const SearchResponse = Schema.Struct({
search_id: Schema.String,
results: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
excerpts: Schema.Array(Schema.String),
}),
),
warnings: Schema.NullOr(
Schema.Array(
Schema.Struct({
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
message: Schema.String,
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
}),
),
).pipe(Schema.optional),
usage: Schema.NullOr(
Schema.Array(
Schema.Struct({
name: Schema.String,
count: Schema.Int,
}),
),
).pipe(Schema.optional),
session_id: Schema.String,
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
structuredContent: SearchResponse,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
integrationID: "parallel",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "parallel",
method: { type: "env", names: ["PARALLEL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "parallel",
name: "Parallel",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
{ input: McpInput, output: McpOutput },
{
objective: input.query,
search_queries: [input.query],
},
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
return (
result?.structuredContent.results.map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
}) ?? []
)
}),
})
})
}),
})