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

@ -943,10 +943,6 @@ export type Endpoint26_2Input = {
readonly location?: Endpoint26_2Request["query"]["location"]
readonly query: Endpoint26_2Request["payload"]["query"]
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
readonly numResults?: Endpoint26_2Request["payload"]["numResults"]
readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"]
readonly type?: Endpoint26_2Request["payload"]["type"]
readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"]
}
export type Endpoint26_2Output = EffectValue<ReturnType<RawClient["server.search"]["search.query"]>>
export type SearchQueryOperation<E = never> = (input: Endpoint26_2Input) => Effect.Effect<Endpoint26_2Output, E>

View file

@ -1120,22 +1120,11 @@ type Endpoint26_2Input = {
readonly location?: Endpoint26_2Request["query"]["location"]
readonly query: Endpoint26_2Request["payload"]["query"]
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
readonly numResults?: Endpoint26_2Request["payload"]["numResults"]
readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"]
readonly type?: Endpoint26_2Request["payload"]["type"]
readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"]
}
const Endpoint26_2 = (raw: RawClient["server.search"]) => (input: Endpoint26_2Input) =>
raw["search.query"]({
query: { location: input["location"] },
payload: {
query: input["query"],
providerID: input["providerID"],
numResults: input["numResults"],
livecrawl: input["livecrawl"],
type: input["type"],
contextMaxCharacters: input["contextMaxCharacters"],
},
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup26 = (raw: RawClient["server.search"]) => ({

View file

@ -1600,14 +1600,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/search`,
query: { location: input["location"] },
body: {
query: input["query"],
providerID: input["providerID"],
numResults: input["numResults"],
livecrawl: input["livecrawl"],
type: input["type"],
contextMaxCharacters: input["contextMaxCharacters"],
},
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,

View file

@ -6307,54 +6307,8 @@ export type SearchQueryInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly query: {
readonly query: string
readonly providerID?: string
readonly numResults?: number
readonly livecrawl?: "fallback" | "preferred"
readonly type?: "auto" | "fast" | "deep"
readonly contextMaxCharacters?: number
}["query"]
readonly providerID?: {
readonly query: string
readonly providerID?: string
readonly numResults?: number
readonly livecrawl?: "fallback" | "preferred"
readonly type?: "auto" | "fast" | "deep"
readonly contextMaxCharacters?: number
}["providerID"]
readonly numResults?: {
readonly query: string
readonly providerID?: string
readonly numResults?: number
readonly livecrawl?: "fallback" | "preferred"
readonly type?: "auto" | "fast" | "deep"
readonly contextMaxCharacters?: number
}["numResults"]
readonly livecrawl?: {
readonly query: string
readonly providerID?: string
readonly numResults?: number
readonly livecrawl?: "fallback" | "preferred"
readonly type?: "auto" | "fast" | "deep"
readonly contextMaxCharacters?: number
}["livecrawl"]
readonly type?: {
readonly query: string
readonly providerID?: string
readonly numResults?: number
readonly livecrawl?: "fallback" | "preferred"
readonly type?: "auto" | "fast" | "deep"
readonly contextMaxCharacters?: number
}["type"]
readonly contextMaxCharacters?: {
readonly query: string
readonly providerID?: string
readonly numResults?: number
readonly livecrawl?: "fallback" | "preferred"
readonly type?: "auto" | "fast" | "deep"
readonly contextMaxCharacters?: number
}["contextMaxCharacters"]
readonly query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
}
export type SearchQueryOutput = {

View file

@ -64,14 +64,13 @@ test("search.query uses the public HTTP contract", async () => {
const result = await client.search.query({
query: "opencode",
providerID: "exa",
numResults: 5,
location: { directory: "/tmp/project" },
})
expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject")
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 })
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("search provider methods use the public HTTP contract", async () => {

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,
},
])
}),

View file

@ -2,16 +2,12 @@ export * as Search from "./search.js"
import { Schema } from "effect"
import { IntegrationID } from "./integration-id.js"
import { optional, PositiveInt } from "./schema.js"
import { optional } from "./schema.js"
export interface Input extends Schema.Schema.Type<typeof Input> {}
export const Input = Schema.Struct({
query: Schema.String,
providerID: IntegrationID.pipe(optional),
numResults: PositiveInt.check(Schema.isLessThanOrEqualTo(20)).pipe(optional),
livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(optional),
type: Schema.Literals(["auto", "fast", "deep"]).pipe(optional),
contextMaxCharacters: PositiveInt.check(Schema.isLessThanOrEqualTo(50_000)).pipe(optional),
}).annotate({ identifier: "Search.Input" })
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}

View file

@ -8339,10 +8339,6 @@ export class Search extends HeyApiClient {
} | null
query?: string
providerID?: string
numResults?: number
livecrawl?: "fallback" | "preferred"
type?: "auto" | "fast" | "deep"
contextMaxCharacters?: number
},
options?: Options<never, ThrowOnError>,
) {
@ -8354,10 +8350,6 @@ export class Search extends HeyApiClient {
{ in: "query", key: "location" },
{ in: "body", key: "query" },
{ in: "body", key: "providerID" },
{ in: "body", key: "numResults" },
{ in: "body", key: "livecrawl" },
{ in: "body", key: "type" },
{ in: "body", key: "contextMaxCharacters" },
],
},
],

View file

@ -19036,10 +19036,6 @@ export type V2SearchQueryData = {
body: {
query: string
providerID?: string
numResults?: number
livecrawl?: "fallback" | "preferred"
type?: "auto" | "fast" | "deep"
contextMaxCharacters?: number
}
path?: never
query?: {