feat(core): persist websearch provider selection (#43268)

This commit is contained in:
Dax 2026-08-18 13:32:08 -04:00 committed by GitHub
parent 67fe76057e
commit 3f79699bce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 57 additions and 21 deletions

View file

@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js"
@ -30,7 +29,6 @@ export const Plugin = {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const config = yield* Config.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
@ -97,9 +95,7 @@ export const Plugin = {
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* config.update((draft) => {
draft.websearch = false
})
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
@ -131,11 +127,7 @@ export const Plugin = {
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* config.update((draft) => {
draft.websearch = {
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
}
})
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
@ -206,7 +198,10 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const disabled = Config.latest(yield* config.entries(), "websearch") === false
const disabled = yield* websearch.default().pipe(
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
}),
)

View file

@ -1,9 +1,10 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { State } from "./state.js"
export const ID = WebSearch.ID
@ -24,6 +25,10 @@ export type Result = WebSearch.Result
export const Response = WebSearch.Response
export type Response = WebSearch.Response
export const ProviderKey = "websearch:provider"
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
export type Selection = typeof Selection.Type
export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
}
@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
@ -56,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = {
readonly providers: Map<ID, ProviderImplementation>
selection?: ID | "random" | false
selection?: Selection
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | "random" | false | undefined
set: (selection: ID | "random" | false) => void
get: () => Selection | undefined
set: (selection: Selection) => void
}
}
@ -71,6 +77,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
@ -91,12 +98,16 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
if (data.selection === false) return yield* new DisabledError()
if (data.selection === "random") {
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
const decoded = Schema.decodeUnknownOption(Selection)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return data.selection ? data.providers.get(data.selection) : undefined
return selection ? data.providers.get(selection) : undefined
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
@ -120,6 +131,9 @@ const layer = Layer.effect(
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
@ -135,5 +149,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
deps: [Bus.node, KV.node],
})

View file

@ -29,7 +29,7 @@ const webSearchToolNode = makeLocationNode({
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
}),
),
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node],
deps: [Tool.node, Permission.node, WebSearch.node, Form.node],
})
const sessionID = Session.ID.make("ses_websearch_test")
@ -93,6 +93,7 @@ const websearch = Layer.succeed(
if (selection === false) return yield* new WebSearch.DisabledError()
return selection ? providers.find((provider) => provider.id === selection) : undefined
}),
select: (next) => Effect.sync(() => (selection = next)),
query: (input) =>
Effect.gen(function* () {
queries.push(input)

View file

@ -3,10 +3,11 @@ import { Effect, Exit, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const register = (id: string) =>
Effect.gen(function* () {
@ -80,6 +81,31 @@ describe("WebSearch", () => {
}),
)
it.effect("persists the selected provider in KV", () =>
Effect.gen(function* () {
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* websearch.select(parallel.providerID)
expect(yield* kv.get(WebSearch.ProviderKey)).toBe(parallel.providerID)
expect((yield* websearch.query({ query: "remembered" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("keeps config transforms above the persisted selection", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.providerID)
yield* websearch.transform((draft) => draft.default.set(exa.providerID))
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(exa.providerID)
}),
)
it.effect("chooses a registered provider for random selection", () =>
Effect.gen(function* () {
yield* register("exa")