feat(core): add integration-backed search
This commit is contained in:
parent
41d11a8a89
commit
129012c3ee
50 changed files with 1745 additions and 478 deletions
|
|
@ -22,6 +22,7 @@ import { ConfigMCP } from "./config/mcp"
|
|||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigSearch } from "./config/search"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
|
|
@ -101,6 +102,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
search: ConfigSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
|
|
|
|||
8
packages/core/src/config/search.ts
Normal file
8
packages/core/src/config/search.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * as ConfigSearch from "./search"
|
||||
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigSearch.Info")({
|
||||
provider: Integration.ID,
|
||||
}) {}
|
||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -44,5 +44,6 @@ export const migrations = (
|
|||
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
|
||||
import("./migration/20260703181610_event_created_column"),
|
||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||
import("./migration/20260706133920_integration-search"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260706133920_integration-search",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`integration_capability\` (
|
||||
\`capability\` text PRIMARY KEY,
|
||||
\`integration_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -87,6 +87,14 @@ export default {
|
|||
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`integration_capability\` (
|
||||
\`capability\` text PRIMARY KEY,
|
||||
\`integration_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`permission\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
|||
export type CreateInput =
|
||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.IntegrationInfo, "id"> & { readonly id?: ID })
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly id: ID
|
||||
|
|
@ -138,7 +139,9 @@ export const layer = Layer.effect(
|
|||
const form: Info =
|
||||
input.mode === "form"
|
||||
? { ...base, mode: "form", fields: input.fields }
|
||||
: { ...base, mode: "url", url: input.url }
|
||||
: input.mode === "url"
|
||||
? { ...base, mode: "url", url: input.url }
|
||||
: { ...base, mode: "integration", integrationID: input.integrationID }
|
||||
const entry: Entry = {
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
|
|
@ -228,9 +231,9 @@ export const locationLayer = layer
|
|||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
function validateAnswer(form: Info, answer: Answer) {
|
||||
if (form.mode === "url") {
|
||||
if (form.mode !== "form") {
|
||||
if (Object.keys(answer).length === 0) return
|
||||
return "URL forms must be answered with an empty answer"
|
||||
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
|
||||
}
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||
for (const key of Object.keys(answer)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * as Integration from "./integration"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
|
|
@ -16,10 +17,13 @@ import {
|
|||
Types,
|
||||
} from "effect"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Search } from "@opencode-ai/schema/search"
|
||||
import { Credential } from "./credential"
|
||||
import { Database } from "./database/database"
|
||||
import { State } from "./state"
|
||||
import { EventV2 } from "./event"
|
||||
import { IntegrationConnection } from "./integration/connection"
|
||||
import { IntegrationCapabilityTable } from "./integration/sql"
|
||||
|
||||
export const ID = Integration.ID
|
||||
export type ID = Integration.ID
|
||||
|
|
@ -60,6 +64,12 @@ export type Info = Integration.Info
|
|||
export const Inputs = Integration.Inputs
|
||||
export type Inputs = Integration.Inputs
|
||||
|
||||
export const SearchCapability = Integration.SearchCapability
|
||||
export type SearchCapability = Omit<Integration.SearchCapability, "selected">
|
||||
|
||||
export const Capability = Integration.Capability
|
||||
export type Capability = Integration.Capability
|
||||
|
||||
export type OAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
|
|
@ -94,6 +104,15 @@ export interface EnvImplementation {
|
|||
|
||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
||||
|
||||
export interface SearchImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly capability: SearchCapability
|
||||
readonly execute: (
|
||||
input: Search.Input,
|
||||
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
|
||||
) => Effect.Effect<Search.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export const Attempt = Integration.Attempt
|
||||
export type Attempt = Integration.Attempt
|
||||
|
||||
|
|
@ -119,6 +138,7 @@ type Entry = {
|
|||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
search?: Types.DeepMutable<SearchImplementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
|
|
@ -135,6 +155,13 @@ export type Draft = {
|
|||
update: (implementation: Implementation) => void
|
||||
remove: (integrationID: ID, method: Method) => void
|
||||
}
|
||||
capability: {
|
||||
search: {
|
||||
list: () => readonly SearchImplementation[]
|
||||
update: (implementation: SearchImplementation) => void
|
||||
remove: (integrationID: ID) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
|
|
@ -191,6 +218,14 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
/** Cancels an attempt and releases its resources. */
|
||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||
}
|
||||
readonly capability: {
|
||||
readonly search: {
|
||||
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
|
||||
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
|
||||
readonly selected: () => Effect.Effect<ID | undefined>
|
||||
readonly select: (integrationID: ID) => Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
|
|
@ -222,6 +257,7 @@ const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const { db } = yield* Database.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
|
|
@ -281,6 +317,32 @@ const layer = Layer.effect(
|
|||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
},
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: () =>
|
||||
Array.from(draft.integrations.values()).flatMap((entry) =>
|
||||
entry.search ? [entry.search as SearchImplementation] : [],
|
||||
),
|
||||
update: (implementation) => {
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
current.search = implementation as Types.DeepMutable<SearchImplementation>
|
||||
},
|
||||
remove: (integrationID) => {
|
||||
const current = draft.integrations.get(integrationID)
|
||||
if (current) delete current.search
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
|
@ -300,14 +362,24 @@ const layer = Layer.effect(
|
|||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
capabilities: entry.search ? [{ ...entry.search.capability, selected: entry.ref.id === selectedSearch }] : [],
|
||||
connections,
|
||||
})
|
||||
|
||||
const selectedSearch = Effect.fn("Integration.capability.search.selected")(function* () {
|
||||
return (yield* db
|
||||
.select({ integrationID: IntegrationCapabilityTable.integration_id })
|
||||
.from(IntegrationCapabilityTable)
|
||||
.where(eq(IntegrationCapabilityTable.capability, "search"))
|
||||
.get()
|
||||
.pipe(Effect.orDie))?.integrationID
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
|
||||
|
||||
|
|
@ -369,12 +441,13 @@ const layer = Layer.effect(
|
|||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)), yield* selectedSearch())
|
||||
}),
|
||||
list: Effect.fn("Integration.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
const selected = yield* selectedSearch()
|
||||
return Array.from(state.get().integrations.values(), (entry) =>
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? []), selected),
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connection: {
|
||||
|
|
@ -514,8 +587,36 @@ const layer = Layer.effect(
|
|||
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||
}),
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: Effect.fn("Integration.capability.search.list")(function* () {
|
||||
return Array.from(state.get().integrations.values()).flatMap((entry) =>
|
||||
entry.search ? [entry.search as SearchImplementation] : [],
|
||||
)
|
||||
}),
|
||||
get: Effect.fn("Integration.capability.search.get")(function* (integrationID) {
|
||||
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
|
||||
}),
|
||||
selected: selectedSearch,
|
||||
select: Effect.fn("Integration.capability.search.select")(function* (integrationID) {
|
||||
if (!state.get().integrations.get(integrationID)?.search) {
|
||||
return yield* Effect.die(new Error(`Search capability not found: ${integrationID}`))
|
||||
}
|
||||
yield* db
|
||||
.insert(IntegrationCapabilityTable)
|
||||
.values({ capability: "search", integration_id: integrationID })
|
||||
.onConflictDoUpdate({
|
||||
target: IntegrationCapabilityTable.capability,
|
||||
set: { integration_id: integrationID },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, Database.node, EventV2.node] })
|
||||
|
|
|
|||
9
packages/core/src/integration/sql.ts
Normal file
9
packages/core/src/integration/sql.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
export const IntegrationCapabilityTable = sqliteTable("integration_capability", {
|
||||
capability: text().$type<Integration.Capability["type"]>().primaryKey(),
|
||||
integration_id: text().$type<Integration.ID>().notNull(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
|
@ -34,6 +34,7 @@ import { Pty } from "./pty"
|
|||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { Search } from "./search"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
|
|
@ -51,7 +52,6 @@ import { SessionInstructions } from "./session/instructions"
|
|||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { WebSearchTool } from "./tool/websearch"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
|
|
@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({
|
|||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Search.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
|
|
@ -100,6 +100,7 @@ const locationServiceNodes = [
|
|||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
Search.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
AISDK.node,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)),
|
||||
connectKey: (input) =>
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
|
|
@ -268,6 +269,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: () =>
|
||||
draft.capability.search.list().map((provider) => ({
|
||||
integrationID: provider.integrationID,
|
||||
capability: provider.capability,
|
||||
execute: (input, context) =>
|
||||
provider.execute(input, {
|
||||
...context,
|
||||
credential: context.credential
|
||||
? Schema.decodeUnknownSync(Credential.Value)(context.credential)
|
||||
: undefined,
|
||||
}),
|
||||
})),
|
||||
update: (input) =>
|
||||
draft.capability.search.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
capability: input.capability,
|
||||
execute: input.execute,
|
||||
}),
|
||||
remove: (id) => draft.capability.search.remove(Integration.ID.make(id)),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ModelsDev } from "../models-dev"
|
|||
import { Npm } from "../npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { Search } from "../search"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
|
|
@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent"
|
|||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SearchPlugins } from "./search"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
|
|
@ -76,13 +78,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const search = yield* Search.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const todo = yield* SessionTodo.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
Context.make(Catalog.Service, catalog),
|
||||
|
|
@ -105,13 +107,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(Search.Service, search),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(SessionTodo.Service, todo),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -127,6 +129,7 @@ const pre = [
|
|||
SkillPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
...SearchPlugins,
|
||||
ApplyPatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
|
|
|
|||
|
|
@ -79,12 +79,42 @@ export function fromPromise(plugin: Plugin) {
|
|||
integration: {
|
||||
list: (input) => run(host.integration.list(input)),
|
||||
get: (input) => run(host.integration.get(input)),
|
||||
selectCapability: (input) => run(host.integration.selectCapability(input)),
|
||||
connectKey: (input) => run(host.integration.connectKey(input)),
|
||||
connectOauth: (input) => run(host.integration.connectOauth(input)),
|
||||
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
|
||||
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
|
||||
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
|
||||
transform: transform(host.integration),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.integration.transform((draft) => {
|
||||
callback({
|
||||
...draft,
|
||||
capability: {
|
||||
search: {
|
||||
list: () =>
|
||||
draft.capability.search.list().map((provider) => ({
|
||||
integrationID: provider.integrationID,
|
||||
capability: provider.capability,
|
||||
execute: (input, execution) =>
|
||||
Effect.runPromiseWith(context)(provider.execute(input, execution)),
|
||||
})),
|
||||
update: (input) =>
|
||||
draft.capability.search.update({
|
||||
integrationID: input.integrationID,
|
||||
capability: input.capability,
|
||||
execute: (query, execution) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => input.execute(query, { ...execution, signal }),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
}),
|
||||
remove: draft.capability.search.remove,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||
|
|
|
|||
53
packages/core/src/plugin/search/exa.ts
Normal file
53
packages/core/src/plugin/search/exa.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export * as SearchExa from "./exa"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { SearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const Args = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const url = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return endpoint
|
||||
const value = new URL(endpoint)
|
||||
value.searchParams.set("exaApiKey", apiKey)
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.search.exa",
|
||||
effect: Effect.fn("SearchExa.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"] } })
|
||||
draft.capability.search.update({
|
||||
integrationID: "exa",
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: (input, context) =>
|
||||
SearchMcp.call(
|
||||
http,
|
||||
url(context.credential?.type === "key" ? context.credential.key : undefined),
|
||||
"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 ?? "" }))),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
4
packages/core/src/plugin/search/index.ts
Normal file
4
packages/core/src/plugin/search/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { SearchExa } from "./exa"
|
||||
import { SearchParallel } from "./parallel"
|
||||
|
||||
export const SearchPlugins = [SearchExa.Plugin, SearchParallel.Plugin] as const
|
||||
75
packages/core/src/plugin/search/mcp.ts
Normal file
75
packages/core/src/plugin/search/mcp.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
export * as SearchMcp 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
|
||||
|
||||
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* () {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
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(Request(args))({
|
||||
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"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
47
packages/core/src/plugin/search/parallel.ts
Normal file
47
packages/core/src/plugin/search/parallel.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export * as SearchParallel from "./parallel"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { SearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const Args = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.search.parallel",
|
||||
effect: Effect.fn("SearchParallel.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"] } })
|
||||
draft.capability.search.update({
|
||||
integrationID: "parallel",
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: (input, context) =>
|
||||
SearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
Args,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID ?? "opencode",
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
|
||||
},
|
||||
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
204
packages/core/src/search.ts
Normal file
204
packages/core/src/search.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
export * as Search from "./search"
|
||||
|
||||
import { Search } from "@opencode-ai/schema/search"
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Form } from "./form"
|
||||
import { Integration } from "./integration"
|
||||
import { truthy } from "./flag/flag"
|
||||
|
||||
export const Input = Search.Input
|
||||
export type Input = Search.Input
|
||||
|
||||
export const ProviderOutput = Search.ProviderOutput
|
||||
export type ProviderOutput = Search.ProviderOutput
|
||||
|
||||
export const Result = Search.Result
|
||||
export type Result = Search.Result
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"Search.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("Search.ProviderNotFound", {
|
||||
providerID: Integration.ID,
|
||||
}) {}
|
||||
|
||||
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
|
||||
"Search.ConnectionRequired",
|
||||
{ providerID: Integration.ID },
|
||||
) {}
|
||||
|
||||
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Search.Cancelled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("Search.Request", {
|
||||
providerID: Integration.ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| ProviderRequiredError
|
||||
| ProviderNotFoundError
|
||||
| ConnectionRequiredError
|
||||
| CancelledError
|
||||
| RequestError
|
||||
|
||||
export interface QueryInput extends Input {
|
||||
readonly sessionID?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Search") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const forms = yield* Form.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const onboarding = Semaphore.makeUnsafe(1)
|
||||
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
|
||||
|
||||
const available = Effect.fn("Search.available")(function* () {
|
||||
return new Map(
|
||||
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
|
||||
)
|
||||
})
|
||||
|
||||
const requireProvider = (
|
||||
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||
providerID: Integration.ID,
|
||||
) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const configured = Effect.fn("Search.configured")(function* () {
|
||||
const providerID = Config.latest(yield* config.entries(), "search")?.provider
|
||||
if (providerID) return providerID
|
||||
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
||||
return Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER)
|
||||
}
|
||||
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
|
||||
return Integration.ID.make("parallel")
|
||||
}
|
||||
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
|
||||
return Integration.ID.make("exa")
|
||||
}
|
||||
})
|
||||
|
||||
const ask = Effect.fn("Search.ask")(function* (
|
||||
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||
sessionID: string,
|
||||
) {
|
||||
if (providers.size === 0) return yield* new ProviderRequiredError()
|
||||
const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "search.provider" },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "This becomes your default and can be changed later from Connect integration.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: Array.from(providers.values())
|
||||
.flatMap((provider) => {
|
||||
const info = infos.get(provider.integrationID)
|
||||
return info ? [{ provider, info }] : []
|
||||
})
|
||||
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
|
||||
.map(({ provider, info }) => ({
|
||||
value: info.id,
|
||||
label: info.name,
|
||||
description: info.connections.length
|
||||
? "Connected"
|
||||
: provider.capability.connection === "optional"
|
||||
? "Keyless available"
|
||||
: "Connection required",
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const answer = state.answer.provider
|
||||
if (typeof answer !== "string") return yield* new ProviderRequiredError()
|
||||
return yield* requireProvider(providers, Integration.ID.make(answer))
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Search.connect")(function* (
|
||||
provider: Integration.SearchImplementation,
|
||||
sessionID?: string,
|
||||
) {
|
||||
const active = yield* integrations.connection.active(provider.integrationID)
|
||||
if (active || provider.capability.connection === "optional") return active
|
||||
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: `Connect ${provider.integrationID}`,
|
||||
metadata: { kind: "integration.connection" },
|
||||
mode: "integration",
|
||||
integrationID: provider.integrationID,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const connected = yield* integrations.connection.active(provider.integrationID)
|
||||
if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
return connected
|
||||
})
|
||||
|
||||
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
|
||||
const providers = yield* available()
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const override = yield* configured()
|
||||
if (override) return yield* requireProvider(providers, override)
|
||||
const selected = yield* integrations.capability.search.selected()
|
||||
const provider = selected ? providers.get(selected) : undefined
|
||||
if (provider) return provider
|
||||
const sessionID = input.sessionID
|
||||
if (!sessionID) return yield* new ProviderRequiredError()
|
||||
return yield* onboarding.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* integrations.capability.search.selected()
|
||||
const selected = current ? providers.get(current) : undefined
|
||||
if (selected) return selected
|
||||
const provider = yield* ask(providers, sessionID)
|
||||
yield* connect(provider, sessionID)
|
||||
yield* integrations.capability.search.select(provider.integrationID)
|
||||
return provider
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const query = Effect.fn("Search.query")(function* (input: QueryInput) {
|
||||
const provider = yield* select(input)
|
||||
const connection = yield* connect(provider, input.sessionID)
|
||||
const credential = connection
|
||||
? yield* integrations.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
||||
: undefined
|
||||
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
||||
Effect.flatMap(decodeOutput),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
||||
)
|
||||
return new Result({ providerID: provider.integrationID, ...output })
|
||||
})
|
||||
|
||||
return Service.of({ query })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] })
|
||||
|
|
@ -2,197 +2,58 @@ export * as WebSearchTool from "./websearch"
|
|||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
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"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
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 = 256 * 1024
|
||||
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
|
||||
export const parseResponse = SearchMcp.parseResponse
|
||||
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
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 (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
||||
description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`,
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
description: "Live crawl preference when supported by the selected provider",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
description: "Search depth preference when supported by the selected provider",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||
{
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
},
|
||||
{ description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` },
|
||||
),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
|
||||
|
||||
/** Isolates the retained product environment contract from the generic tool implementation. */
|
||||
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
ConfigService.of({
|
||||
provider:
|
||||
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
|
||||
? process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
: undefined,
|
||||
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
||||
}),
|
||||
)
|
||||
|
||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
override?: Provider,
|
||||
): Provider {
|
||||
if (override) return override
|
||||
if (flags.enableParallel) return "parallel"
|
||||
if (flags.enableExa) return "exa"
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("WebSearchTool.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
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const ExaArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
const ParallelArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
const exaUrl = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return EXA_URL
|
||||
const url = new URL(EXA_URL)
|
||||
url.searchParams.set("exaApiKey", apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const callMcp = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
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(McpRequest(args))({
|
||||
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"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
provider: Provider,
|
||||
provider: Integration.ID,
|
||||
text: Schema.String,
|
||||
metadata: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const permission = yield* PermissionV2.Service
|
||||
const search = yield* Search.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
|
|
@ -201,50 +62,24 @@ export const Plugin = {
|
|||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: { ...input, provider },
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
const result = yield* search.query({ ...input, sessionID: context.sessionID })
|
||||
return {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
provider: result.providerID,
|
||||
text: result.text || NO_RESULTS,
|
||||
metadata: result.metadata,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
},
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue