diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 28dd4eb491..3248b759b0 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,132 +1,99 @@ import z from "zod" +import { Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { Tool } from "./tool" import DESCRIPTION from "./codesearch.txt" -import { abortAfterAny } from "../util/abort" -const API_CONFIG = { - BASE_URL: "https://mcp.exa.ai", - ENDPOINTS: { - CONTEXT: "/mcp", - }, -} as const +const URL = "https://mcp.exa.ai/mcp" -interface McpCodeRequest { - jsonrpc: string - id: number - method: string - params: { - name: string - arguments: { - query: string - tokensNum: number - } - } -} +export const CodeSearchTool = Tool.defineEffect( + "codesearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient -interface McpCodeResponse { - jsonrpc: string - result: { - content: Array<{ - type: string - text: string - }> - } -} + return { + description: DESCRIPTION, + parameters: z.object({ + query: z + .string() + .describe( + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + ), + tokensNum: z + .number() + .min(1000) + .max(50000) + .default(5000) + .describe( + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + ), + }), + execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* Effect.promise(() => + ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }), + ) -export const CodeSearchTool = Tool.define("codesearch", { - description: DESCRIPTION, - parameters: z.object({ - query: z - .string() - .describe( - "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - ), - tokensNum: z - .number() - .min(1000) - .max(50000) - .default(5000) - .describe( - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - ), - }), - async execute(params, ctx) { - await ctx.ask({ - permission: "codesearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) + const request = HttpClientRequest.post(URL).pipe( + HttpClientRequest.setHeaders({ + accept: "application/json, text/event-stream", + "content-type": "application/json", + }), + HttpClientRequest.bodyJsonUnsafe({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "get_code_context_exa", + arguments: { + query: params.query, + tokensNum: params.tokensNum || 5000, + }, + }, + }), + ) - const codeRequest: McpCodeRequest = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "get_code_context_exa", - arguments: { - query: params.query, - tokensNum: params.tokensNum || 5000, - }, - }, - } + const response = yield* http.execute(request).pipe(Effect.timeout("30 seconds")) - const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort) + if (response.status < 200 || response.status >= 300) { + const errorText = yield* response.text + throw new Error(`Code search error (${response.status}): ${errorText}`) + } - try { - const headers: Record = { - accept: "application/json, text/event-stream", - "content-type": "application/json", - } + const responseText = yield* response.text - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, { - method: "POST", - headers, - body: JSON.stringify(codeRequest), - signal, - }) - - clearTimeout() - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Code search error (${response.status}): ${errorText}`) - } - - const responseText = await response.text() - - // Parse SSE response - const lines = responseText.split("\n") - for (const line of lines) { - if (line.startsWith("data: ")) { - const data: McpCodeResponse = JSON.parse(line.substring(6)) - if (data.result && data.result.content && data.result.content.length > 0) { - return { - output: data.result.content[0].text, - title: `Code search: ${params.query}`, - metadata: {}, + // Parse SSE response + for (const line of responseText.split("\n")) { + if (line.startsWith("data: ")) { + const data = JSON.parse(line.substring(6)) + if (data.result?.content?.[0]?.text) { + return { + output: data.result.content[0].text, + title: `Code search: ${params.query}`, + metadata: {}, + } + } } } - } - } - return { - output: - "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", - title: `Code search: ${params.query}`, - metadata: {}, - } - } catch (error) { - clearTimeout() - - if (error instanceof Error && error.name === "AbortError") { - throw new Error("Code search request timed out") - } - - throw error + return { + output: + "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", + title: `Code search: ${params.query}`, + metadata: {}, + } + }).pipe( + Effect.catchTag("TimeoutError", () => Effect.die(new Error("Code search request timed out"))), + Effect.runPromise, + ), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9c0771b8df..0ba609499f 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -28,6 +28,7 @@ import { Glob } from "../util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" @@ -80,6 +81,7 @@ export namespace ToolRegistry { | FileTime.Service | Instruction.Service | AppFileSystem.Service + | HttpClient.HttpClient > = Layer.effect( Service, Effect.gen(function* () { @@ -92,6 +94,7 @@ export namespace ToolRegistry { const read = yield* ReadTool const question = yield* QuestionTool const todo = yield* TodoWriteTool + const codesearch = yield* CodeSearchTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -160,7 +163,7 @@ export namespace ToolRegistry { fetch: Tool.init(WebFetchTool), todo: Tool.init(todo), search: Tool.init(WebSearchTool), - code: Tool.init(CodeSearchTool), + code: Tool.init(codesearch), skill: Tool.init(SkillTool), patch: Tool.init(ApplyPatchTool), question: Tool.init(question), @@ -301,6 +304,7 @@ export namespace ToolRegistry { Layer.provide(FileTime.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), ), ) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 215f6668cf..c114af6510 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1,4 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" +import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" @@ -169,6 +170,7 @@ function makeHttp() { const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ae67983bf6..878468f562 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { AppFileSystem } from "../../src/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { FetchHttpClient } from "effect/unstable/http" Log.init({ print: false }) @@ -134,6 +135,7 @@ function makeHttp() { const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps),