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

@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@ -9,6 +10,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/util/npm"
@ -19,6 +21,7 @@ import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@ -39,6 +42,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Npm.node,
Credential.node,
EventV2.node,
Form.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
@ -52,9 +56,11 @@ export const PluginTestLayer = AppNodeBuilder.build(
SkillV2.node,
ToolHooks.node,
ToolRegistry.toolsNode,
WebSearch.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
],
) as unknown as Layer.Layer<unknown, never>

View file

@ -1,11 +1,16 @@
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WebSearch } from "@opencode-ai/core/websearch"
import type {
CredentialOAuth,
IntegrationCommandMethod,
IntegrationEnvMethod,
IntegrationKeyMethod,
@ -13,11 +18,11 @@ import type {
} from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
readonly session?: Partial<PluginContext["session"]>
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
readonly session?: Partial<Plugin.Context["session"]>
}
export function host(overrides: Overrides = {}): PluginContext {
export function host(overrides: Overrides = {}): Plugin.Context {
return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
options: {},
@ -92,6 +97,12 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),
transform: () => Effect.die("unused websearch.transform"),
reload: () => Effect.die("unused websearch.reload"),
},
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
@ -105,7 +116,7 @@ export function host(overrides: Overrides = {}): PluginContext {
}
}
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
return {
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
list: () => Effect.die("unused agent.list"),
@ -131,7 +142,7 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
}
}
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
return {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
@ -207,7 +218,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
}
}
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
@ -329,6 +340,40 @@ export function integrationHost(integration: Integration.Interface): PluginConte
}
}
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
const location = Location.Info.make({
directory: AbsolutePath.make("/tmp/websearch-test"),
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
})
return {
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
query: (input) =>
websearch
.query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) })
.pipe(Effect.map((data) => ({ location, data }))),
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)),
},
})
}),
}
}
function oauthCredential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
function method(value: Integration.Method) {
if (value.type === "env") return { type: value.type, names: [...value.names] }
if (value.type === "key") return { type: value.type, label: value.label }

View file

@ -8,6 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPending } from "@opencode-ai/core/session/pending"
@ -244,6 +245,38 @@ describe("fromPromise", () => {
}),
)
it.effect("registers a standalone web search provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
id: "promise-websearch",
setup: async (ctx) => {
await ctx.websearch.transform((draft) => {
draft.add({
id: "promise-websearch",
name: "Promise Web Search",
execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("promise-websearch"),
name: "Promise Web Search",
})
expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("promise-websearch"),
results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
}),
)
}),
)
it.effect("runs the setup cleanup when the plugin scope closes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service

View file

@ -0,0 +1,50 @@
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "../lib/effect"
interface WebSearchRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
export const requests: WebSearchRequest[] = []
let responseBody = ""
export function resetWebSearchFixture(body: string) {
requests.length = 0
responseBody = body
}
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
}),
),
)
export const webSearchIntegrationTest = testEffect(
Layer.merge(
AppNodeBuilder.build(
LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, WebSearch.node]),
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
),
http,
),
)

View file

@ -0,0 +1,170 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
resetWebSearchFixture(
`event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [
{
type: "text",
text: "Title: Effect\nURL: https://effect.website\nPublished: 2026-07-25T00:00:00.000Z\nAuthor: N/A\nHighlights:\nEffect documentation",
_meta: { searchTime: 123 },
},
],
},
})}\n\n`,
)
})
const it = webSearchIntegrationTest
describe("built-in web search providers", () => {
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
const registration = yield* webSearchHost(websearch).transform((draft) => {
draft.add({
id: "test-websearch",
name: "Test Web Search",
execute: (input) => Effect.succeed([{ url: "https://example.com", content: input.query, time: {} }]),
})
})
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
yield* registration.dispose
expect(yield* websearch.providers()).not.toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchExa.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const info = yield* integrations.get(Integration.ID.make("exa"))
expect(info).toMatchObject({
id: "exa",
name: "Exa",
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
})
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
}),
)
expect(requests).toEqual([
{
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: { query: "effect typescript", numResults: 8 },
},
},
},
])
}),
)
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
Effect.gen(function* () {
resetWebSearchFixture(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [{ type: "text", text: "search results" }],
structuredContent: {
search_id: "search_1",
results: [
{
url: "https://effect.website",
title: "Effect",
publish_date: null,
excerpts: ["Effect documentation"],
},
],
warnings: null,
usage: [{ name: "sku_search", count: 1 }],
session_id: "ses_parallel",
},
},
}),
)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchParallel.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
const output = yield* websearch.query({
query: "effect layers",
providerID: WebSearch.ID.make("parallel"),
})
expect(output).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: {},
},
],
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: {
objective: "effect layers",
search_queries: ["effect layers"],
},
},
},
})
expect(JSON.stringify(output)).not.toContain("parallel-secret")
}),
)
})