refactor(search): isolate provider schemas

This commit is contained in:
Shoubhit Dash 2026-07-08 17:17:26 +05:30
commit 97aa6713b5
14 changed files with 151 additions and 227 deletions

View file

@ -7,12 +7,19 @@ import { SearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
const Args = Schema.Struct({
const Input = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
numResults: Schema.Number.pipe(Schema.optional),
})
const Output = 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>({
@ -31,13 +38,21 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
execute: (input, context) => {
const url = new URL(endpoint)
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
return SearchMcp.call(http, url.toString(), "web_search_exa", Args, {
query: input.query,
type: input.type ?? "auto",
numResults: input.numResults ?? 8,
livecrawl: input.livecrawl ?? "fallback",
contextMaxCharacters: input.contextMaxCharacters,
}).pipe(Effect.map((text) => ({ text: text ?? "" })))
return SearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: Input, output: Output },
{ query: input.query },
).pipe(
Effect.map((result) => {
const content = result?.content.find((item) => item.text)
return {
text: content?.text ?? "",
...(content?._meta ? { metadata: content._meta } : {}),
}
}),
)
},
},
})

View file

@ -6,30 +6,24 @@ import { collectBoundedResponseBody } from "../../tool/http-body"
export const MAX_RESPONSE_BYTES = 256 * 1024
const Result = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
}),
})
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
const parsePayload = (payload: string) =>
Effect.gen(function* () {
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 undefined
return (yield* decodeResult(trimmed)).result.content.find((item) => item.text)?.text
})
export const parseResponse = Effect.fn("SearchMcp.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* parsePayload(line.substring(6))
if (data) return data
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
}
})
}
const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
@ -39,11 +33,11 @@ const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
params: Schema.Struct({ name: Schema.String, arguments: args }),
})
export const call = <F extends Schema.Struct.Fields>(
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
args: Schema.Struct<F>,
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
@ -51,7 +45,7 @@ export const call = <F extends Schema.Struct.Fields>(
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(Request(args))({
HttpClientRequest.schemaBodyJson(Request(schema.input))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
@ -65,7 +59,7 @@ export const call = <F extends Schema.Struct.Fields>(
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"))
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),

View file

@ -8,11 +8,46 @@ import { SearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
const Args = Schema.Struct({
const Input = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
session_id: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
})
const Metadata = 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 Output = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
structuredContent: Metadata,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.parallel",
@ -32,17 +67,25 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
http,
endpoint,
"web_search",
Args,
{ input: Input, output: Output },
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID ?? "opencode",
...(context.sessionID ? { session_id: context.sessionID } : {}),
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
},
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
).pipe(
Effect.map((result) => {
const content = result?.content.find((item) => item.text)
return {
text: content?.text ?? "",
...(result ? { metadata: result.structuredContent } : {}),
}
}),
),
},
})
}),

View file

@ -4,43 +4,19 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plu
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Integration } from "../integration"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { Search } from "../search"
import { SearchExa } from "../plugin/search/exa"
import { SearchMcp } from "../plugin/search/mcp"
import { SearchParallel } from "../plugin/search/parallel"
import { Tool } from "./tool"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
export const EXA_URL = SearchExa.endpoint
export const PARALLEL_URL = SearchParallel.endpoint
export const MAX_NUM_RESULTS = 20
export const MAX_CONTEXT_CHARACTERS = 50_000
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
export const parseResponse = SearchMcp.parseResponse
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults.
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Input = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`,
}),
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description: "Live crawl preference when supported by the selected provider",
}),
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search depth preference when supported by the selected provider",
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
{ description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` },
),
})
const Output = Schema.Struct({

View file

@ -9,11 +9,11 @@ import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fi
beforeEach(() => {
resetSearchFixture(
JSON.stringify({
`event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "search results" }] },
}),
result: { content: [{ type: "text", text: "search results", _meta: { searchTime: 123 } }] },
})}\n\n`,
)
})
@ -43,7 +43,7 @@ describe("built-in search integrations", () => {
}),
)
it.effect("registers Exa and maps search hints to its MCP tool", () =>
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
@ -59,16 +59,10 @@ describe("built-in search integrations", () => {
if (!provider) return yield* Effect.die("Expected Exa search provider")
expect(
yield* provider.execute(
{
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
{ query: "effect typescript" },
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
),
).toEqual({ text: "search results" })
).toEqual({ text: "search results", metadata: { searchTime: 123 } })
expect(requests).toEqual([
{
url: `${SearchExa.endpoint}?exaApiKey=exa+secret`,
@ -79,13 +73,7 @@ describe("built-in search integrations", () => {
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query: "effect typescript",
type: "fast",
numResults: 3,
livecrawl: "preferred",
contextMaxCharacters: 2500,
},
arguments: { query: "effect typescript" },
},
},
},
@ -95,6 +83,29 @@ describe("built-in search integrations", () => {
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
Effect.gen(function* () {
resetSearchFixture(
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
yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
const provider = yield* integrations.search.get(Integration.ID.make("parallel"))
@ -107,7 +118,23 @@ describe("built-in search integrations", () => {
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
},
)
expect(output).toEqual({ text: "search results" })
expect(output).toEqual({
text: "search results",
metadata: {
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",
},
})
expect(requests[0]).toMatchObject({
url: SearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV2 } from "@opencode-ai/core/permission"
@ -20,36 +20,6 @@ const webSearchToolNode = makeLocationNode({
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text }] },
})
describe("WebSearchTool input", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
})
})
describe("WebSearchTool MCP response parser", () => {
test("parses plain JSON-RPC responses", async () => {
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
})
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
expect(
await Effect.runPromise(
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
),
).toBe("search results")
})
})
const assertions: PermissionV2.AssertInput[] = []
const queries: Search.QueryInput[] = []
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
@ -105,13 +75,7 @@ describe("WebSearchTool registration", () => {
type: "tool-call",
id: "call-search",
name: "websearch",
input: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
input: { query: "effect typescript" },
},
}),
).toEqual({ type: "text", value: "search results" })
@ -121,23 +85,13 @@ describe("WebSearchTool registration", () => {
action: "websearch",
resources: ["effect typescript"],
save: ["*"],
metadata: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
metadata: { query: "effect typescript" },
},
])
expect(queries).toEqual([
{
sessionID,
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
])
}),