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 } : {}),
}
}),
),
},
})
}),