refactor(tool): convert codesearch to defineEffect with HttpClient

Replace raw fetch with Effect HttpClient service. Remove manual
AbortController/signal/clearTimeout plumbing — fiber interruption
and Effect.timeout handle cancellation natively.
This commit is contained in:
Kit Langton
2026-04-09 23:19:31 -04:00
parent 91786d2fc1
commit 882a821aab
4 changed files with 93 additions and 118 deletions

View File

@@ -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<string, string> = {
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,
),
}
},
})
}),
)

View File

@@ -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<State>(
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),
),
)

View File

@@ -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),

View File

@@ -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),