diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 5765afee4..ea49bd590 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -39,6 +39,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime enabled: bool("OPENCODE_ENABLE_PARALLEL"), legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"), }).pipe(Config.map((flags) => flags.enabled || flags.legacy)), + enableTavily: bool("OPENCODE_ENABLE_TAVILY"), enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"), diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 208924cba..354833f33 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -66,6 +66,53 @@ const McpRequest = (args: Schema.Struct) => }), }) +export const TAVILY_URL = "https://api.tavily.com/search" + +const TavilyResult = Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + title: Schema.String, + url: Schema.String, + content: Schema.String, + score: Schema.Number, + }), + ), +}) + +const decodeTavily = Schema.decodeUnknownEffect(Schema.parseJson(TavilyResult)) + +export const callTavily = ( + http: HttpClient.HttpClient, + params: { query: string; max_results?: number }, +) => + Effect.gen(function* () { + const apiKey = process.env.TAVILY_API_KEY + if (!apiKey) return yield* Effect.die(new Error("TAVILY_API_KEY is not set")) + + const request = yield* HttpClientRequest.post(TAVILY_URL).pipe( + HttpClientRequest.accept("application/json"), + HttpClientRequest.setHeaders({ "Content-Type": "application/json" }), + HttpClientRequest.bodyJson({ + query: params.query, + max_results: params.max_results ?? 8, + search_depth: "advanced", + api_key: apiKey, + }), + ) + const response = yield* HttpClient.filterStatusOk(http) + .execute(request) + .pipe( + Effect.timeoutOrElse({ + duration: "25 seconds", + orElse: () => Effect.die(new Error("Tavily search request timed out")), + }), + ) + const body = yield* response.text + const data = yield* decodeTavily(body) + const texts = data.results.map((r) => `## ${r.title}\n${r.url}\n\n${r.content}`) + return texts.length > 0 ? texts.join("\n\n---\n\n") : undefined + }) + export const call = ( http: HttpClient.HttpClient, url: string, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3f57a95a5..58e43adba 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -58,8 +58,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "tool.registry" }) -export function webSearchEnabled(providerID: ProviderID, flags = { exa: false, parallel: false }) { - return providerID === ProviderID.opencode || flags.exa || flags.parallel +export function webSearchEnabled(providerID: ProviderID, flags = { exa: false, parallel: false, tavily: false }) { + return providerID === ProviderID.opencode || flags.exa || flags.parallel || flags.tavily } type TaskDef = Tool.InferDef @@ -322,7 +322,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) + return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel, tavily: flags.enableTavily }) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index d08ae1d15..68815e9c0 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -24,12 +24,16 @@ export const Parameters = Schema.Struct({ }), }) -const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel", "tavily"]) export type WebSearchProvider = Schema.Schema.Type -export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: false, parallel: false, tavily: false }, +): WebSearchProvider { const override = process.env.OPENCODE_WEBSEARCH_PROVIDER - if (override === "exa" || override === "parallel") return override + if (override === "exa" || override === "parallel" || override === "tavily") return override + if (flags.tavily) return "tavily" if (flags.parallel) return "parallel" if (flags.exa) return "exa" @@ -39,6 +43,7 @@ export function selectWebSearchProvider(sessionID: string, flags = { exa: false, export function webSearchProviderLabel(provider: unknown) { if (provider === "parallel") return "Parallel Web Search" if (provider === "exa") return "Exa Web Search" + if (provider === "tavily") return "Tavily Web Search" return "Web Search" } @@ -63,6 +68,13 @@ function callProvider( params: Schema.Schema.Type, ctx: Tool.Context, ) { + if (provider === "tavily") { + return McpWebSearch.callTavily(http, { + query: params.query, + max_results: params.numResults || 8, + }) + } + if (provider === "parallel") { return McpWebSearch.call( http, @@ -112,6 +124,7 @@ export const WebSearchTool = Tool.define( const provider = selectWebSearchProvider(ctx.sessionID, { exa: flags.enableExa, parallel: flags.enableParallel, + tavily: flags.enableTavily, }) const title = webSearchProviderLabel(provider) yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } })