mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 655ab67157 |
@@ -32,6 +32,7 @@ import { Npm } from "@opencode-ai/util/npm"
|
|||||||
import { Permission } from "../permission"
|
import { Permission } from "../permission"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { WebSearch } from "../websearch"
|
import { WebSearch } from "../websearch"
|
||||||
|
import { WebSearchPreference } from "../websearch-preference"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { Ripgrep } from "../ripgrep"
|
||||||
import { SessionInstructions } from "../session/instructions"
|
import { SessionInstructions } from "../session/instructions"
|
||||||
import { Shell } from "../shell"
|
import { Shell } from "../shell"
|
||||||
@@ -89,6 +90,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const read = yield* ReadToolFileSystem.Service
|
const read = yield* ReadToolFileSystem.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const websearch = yield* WebSearch.Service
|
const websearch = yield* WebSearch.Service
|
||||||
|
const websearchPreference = yield* WebSearchPreference.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const instructions = yield* SessionInstructions.Service
|
const instructions = yield* SessionInstructions.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
@@ -121,6 +123,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
Context.make(ReadToolFileSystem.Service, read),
|
Context.make(ReadToolFileSystem.Service, read),
|
||||||
Context.make(Reference.Service, reference),
|
Context.make(Reference.Service, reference),
|
||||||
Context.make(WebSearch.Service, websearch),
|
Context.make(WebSearch.Service, websearch),
|
||||||
|
Context.make(WebSearchPreference.Service, websearchPreference),
|
||||||
Context.make(Ripgrep.Service, ripgrep),
|
Context.make(Ripgrep.Service, ripgrep),
|
||||||
Context.make(SessionInstructions.Service, instructions),
|
Context.make(SessionInstructions.Service, instructions),
|
||||||
Context.make(Shell.Service, shell),
|
Context.make(Shell.Service, shell),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { Skill } from "../skill"
|
|||||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||||
import { Tool } from "../tool"
|
import { Tool } from "../tool"
|
||||||
import { WebSearch } from "../websearch"
|
import { WebSearch } from "../websearch"
|
||||||
|
import { WebSearchPreference } from "../websearch-preference"
|
||||||
import { WellKnown } from "../wellknown"
|
import { WellKnown } from "../wellknown"
|
||||||
import { PluginInternal } from "./internal"
|
import { PluginInternal } from "./internal"
|
||||||
import { PluginRuntime } from "./runtime"
|
import { PluginRuntime } from "./runtime"
|
||||||
@@ -345,6 +346,7 @@ export const node = makeLocationNode({
|
|||||||
Tool.node,
|
Tool.node,
|
||||||
Watcher.node,
|
Watcher.node,
|
||||||
WebSearch.node,
|
WebSearch.node,
|
||||||
|
WebSearchPreference.node,
|
||||||
WellKnown.node,
|
WellKnown.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Form } from "../../form"
|
import { Form } from "../../form"
|
||||||
import { KV } from "../../kv"
|
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { WebSearch } from "../../websearch"
|
import { WebSearch } from "../../websearch"
|
||||||
|
import { WebSearchPreference } from "../../websearch-preference"
|
||||||
|
|
||||||
export const name = "websearch"
|
export const name = "websearch"
|
||||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||||
@@ -28,7 +28,7 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const forms = yield* Form.Service
|
const forms = yield* Form.Service
|
||||||
const kv = yield* KV.Service
|
const preference = yield* WebSearchPreference.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
@@ -52,65 +52,77 @@ export const Plugin = {
|
|||||||
const result = yield* ctx.websearch.query(input).pipe(
|
const result = yield* ctx.websearch.query(input).pipe(
|
||||||
Effect.catch((error) => {
|
Effect.catch((error) => {
|
||||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||||
return Effect.gen(function* () {
|
return preference
|
||||||
const providers = (yield* ctx.websearch.providers()).data
|
.synchronized(
|
||||||
const defaultProvider = providers[0]
|
Effect.gen(function* () {
|
||||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
const providers = (yield* ctx.websearch.providers()).data
|
||||||
const response = yield* forms.ask({
|
const stored = yield* preference.get()
|
||||||
sessionID: context.sessionID,
|
if (stored === false) return yield* new WebSearch.DisabledError()
|
||||||
title: "Web Search",
|
if (typeof stored === "string" && providers.some((provider) => provider.id === stored))
|
||||||
metadata: { kind: "websearch.provider" },
|
return yield* Effect.void
|
||||||
fields: [
|
const defaultProvider = providers[0]
|
||||||
{
|
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||||
key: "choice",
|
const response = yield* forms.ask({
|
||||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
sessionID: context.sessionID,
|
||||||
type: "string",
|
title: "Web Search",
|
||||||
required: true,
|
metadata: { kind: "websearch.provider" },
|
||||||
custom: false,
|
fields: [
|
||||||
options: [
|
|
||||||
{
|
{
|
||||||
value: "allow",
|
key: "choice",
|
||||||
label: `Allow web search via ${defaultProvider.name}`,
|
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
custom: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "allow",
|
||||||
|
label: `Allow web search via ${defaultProvider.name}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "choose",
|
||||||
|
label: "Choose another provider",
|
||||||
|
},
|
||||||
|
{ value: "disable", label: "Disable web search" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "choose",
|
|
||||||
label: "Choose another provider",
|
|
||||||
},
|
|
||||||
{ value: "disable", label: "Disable web search" },
|
|
||||||
],
|
],
|
||||||
},
|
})
|
||||||
],
|
if (response.status === "cancelled")
|
||||||
})
|
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
if (response.answer.choice === "disable") {
|
||||||
if (response.answer.choice === "disable") {
|
yield* preference.set(false)
|
||||||
yield* kv.set("websearch:provider", false)
|
return yield* new WebSearch.DisabledError()
|
||||||
return yield* new WebSearch.DisabledError()
|
}
|
||||||
}
|
const selection =
|
||||||
const selection =
|
response.answer.choice === "choose"
|
||||||
response.answer.choice === "choose"
|
? yield* forms.ask({
|
||||||
? yield* forms.ask({
|
sessionID: context.sessionID,
|
||||||
sessionID: context.sessionID,
|
title: "Choose a web search provider",
|
||||||
title: "Choose a web search provider",
|
metadata: { kind: "websearch.provider" },
|
||||||
metadata: { kind: "websearch.provider" },
|
fields: [
|
||||||
fields: [
|
{
|
||||||
{
|
key: "provider",
|
||||||
key: "provider",
|
description: "Choose a provider for web search.",
|
||||||
description: "Choose a provider for web search.",
|
type: "string",
|
||||||
type: "string",
|
required: true,
|
||||||
required: true,
|
custom: false,
|
||||||
custom: false,
|
options: providers.map((provider) => ({
|
||||||
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
value: provider.id,
|
||||||
},
|
label: provider.name,
|
||||||
],
|
})),
|
||||||
})
|
},
|
||||||
: undefined
|
],
|
||||||
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
})
|
||||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
: undefined
|
||||||
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
if (selection?.status === "cancelled")
|
||||||
return yield* new WebSearch.ProviderRequiredError()
|
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
yield* kv.set("websearch:provider", providerID)
|
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||||
return yield* ctx.websearch.query(input)
|
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||||
})
|
return yield* new WebSearch.ProviderRequiredError()
|
||||||
|
return yield* preference.set(providerID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.andThen(ctx.websearch.query(input)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const output = {
|
const output = {
|
||||||
@@ -140,7 +152,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.session.hook("context", (event) =>
|
yield* ctx.session.hook("context", (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
|
if ((yield* preference.get()) === false) delete event.tools[name]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export * as WebSearchPreference from "./websearch-preference"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer, Semaphore } from "effect"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { KV } from "./kv"
|
||||||
|
|
||||||
|
const key = "websearch:provider"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: () => Effect.Effect<KV.Value | undefined>
|
||||||
|
readonly set: (value: KV.Value) => Effect.Effect<void>
|
||||||
|
readonly synchronized: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearchPreference") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const kv = yield* KV.Service
|
||||||
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
|
return Service.of({
|
||||||
|
get: () => kv.get(key),
|
||||||
|
set: (value) => kv.set(key, value),
|
||||||
|
synchronized: (effect) => lock.withPermit(effect),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
|
||||||
@@ -4,8 +4,8 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
|
|||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Bus } from "./bus"
|
import { Bus } from "./bus"
|
||||||
import { KV } from "./kv"
|
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
import { WebSearchPreference } from "./websearch-preference"
|
||||||
|
|
||||||
export const ID = WebSearch.ID
|
export const ID = WebSearch.ID
|
||||||
export type ID = WebSearch.ID
|
export type ID = WebSearch.ID
|
||||||
@@ -75,7 +75,7 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const kv = yield* KV.Service
|
const preference = yield* WebSearchPreference.Service
|
||||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
initial: () => ({ providers: new Map() }),
|
initial: () => ({ providers: new Map() }),
|
||||||
@@ -98,7 +98,7 @@ const layer = Layer.effect(
|
|||||||
const data = state.get()
|
const data = state.get()
|
||||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
const stored = yield* kv.get("websearch:provider")
|
const stored = yield* preference.get()
|
||||||
if (stored === false) return yield* new DisabledError()
|
if (stored === false) return yield* new DisabledError()
|
||||||
if (typeof stored !== "string") return
|
if (typeof stored !== "string") return
|
||||||
return data.providers.get(ID.make(stored))
|
return data.providers.get(ID.make(stored))
|
||||||
@@ -140,5 +140,5 @@ const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Bus.node, KV.node],
|
deps: [Bus.node, WebSearchPreference.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { beforeEach, describe, expect } from "bun:test"
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Deferred, Effect, Layer } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { Form } from "@opencode-ai/core/form"
|
import { Form } from "@opencode-ai/core/form"
|
||||||
import { KV } from "@opencode-ai/core/kv"
|
import { KV } from "@opencode-ai/core/kv"
|
||||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||||
|
import { WebSearchPreference } from "@opencode-ai/core/websearch-preference"
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
|
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
|
||||||
@@ -24,7 +25,7 @@ const webSearchToolNode = makeLocationNode({
|
|||||||
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, KV.node],
|
deps: [Tool.node, Permission.node, WebSearch.node, WebSearchPreference.node, Form.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_websearch_test")
|
const sessionID = Session.ID.make("ses_websearch_test")
|
||||||
@@ -39,6 +40,8 @@ const providers = [
|
|||||||
let providerRequired = false
|
let providerRequired = false
|
||||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||||
const formResponses: Form.TerminalState[] = []
|
const formResponses: Form.TerminalState[] = []
|
||||||
|
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||||
|
let synchronizedQueries = 0
|
||||||
let result = new WebSearch.Response({
|
let result = new WebSearch.Response({
|
||||||
providerID: WebSearch.ID.make("exa"),
|
providerID: WebSearch.ID.make("exa"),
|
||||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||||
@@ -52,6 +55,8 @@ beforeEach(() => {
|
|||||||
providerRequired = false
|
providerRequired = false
|
||||||
formResponse = { status: "cancelled" }
|
formResponse = { status: "cancelled" }
|
||||||
formResponses.length = 0
|
formResponses.length = 0
|
||||||
|
queryBarrier = undefined
|
||||||
|
synchronizedQueries = 0
|
||||||
result = new WebSearch.Response({
|
result = new WebSearch.Response({
|
||||||
providerID: WebSearch.ID.make("exa"),
|
providerID: WebSearch.ID.make("exa"),
|
||||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||||
@@ -80,6 +85,11 @@ const websearch = Layer.succeed(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
queries.push(input)
|
queries.push(input)
|
||||||
const stored = values.get("websearch:provider")
|
const stored = values.get("websearch:provider")
|
||||||
|
if (queryBarrier && synchronizedQueries < 5) {
|
||||||
|
synchronizedQueries++
|
||||||
|
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||||
|
yield* Deferred.await(queryBarrier)
|
||||||
|
}
|
||||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||||
if (typeof stored === "string")
|
if (typeof stored === "string")
|
||||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||||
@@ -316,6 +326,35 @@ describe("WebSearchTool registration", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("shares provider consent across concurrent searches", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
providerRequired = true
|
||||||
|
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||||
|
queryBarrier = yield* Deferred.make<void>()
|
||||||
|
const registry = yield* Tool.Service
|
||||||
|
|
||||||
|
const results = yield* Effect.all(
|
||||||
|
Array.from({ length: 5 }, (_, index) =>
|
||||||
|
executeTool(registry, {
|
||||||
|
sessionID,
|
||||||
|
...toolIdentity,
|
||||||
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: `call-concurrent-${index}`,
|
||||||
|
name: "websearch",
|
||||||
|
input: { query: `effect ${index}` },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results.every((item) => item.status === "completed")).toBe(true)
|
||||||
|
expect(formRequests).toHaveLength(1)
|
||||||
|
expect(values.get("websearch:provider")).toBe("exa")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("persists the choice to disable web search", () =>
|
it.effect("persists the choice to disable web search", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
providerRequired = true
|
providerRequired = true
|
||||||
|
|||||||
Reference in New Issue
Block a user