Skip to content

Commit 882a821

Browse files
committed
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.
1 parent 91786d2 commit 882a821

4 files changed

Lines changed: 93 additions & 118 deletions

File tree

Lines changed: 84 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,99 @@
11
import z from "zod"
2+
import { Effect } from "effect"
3+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
24
import { Tool } from "./tool"
35
import DESCRIPTION from "./codesearch.txt"
4-
import { abortAfterAny } from "../util/abort"
56

6-
const API_CONFIG = {
7-
BASE_URL: "https://mcp.exa.ai",
8-
ENDPOINTS: {
9-
CONTEXT: "/mcp",
10-
},
11-
} as const
7+
const URL = "https://mcp.exa.ai/mcp"
128

13-
interface McpCodeRequest {
14-
jsonrpc: string
15-
id: number
16-
method: string
17-
params: {
18-
name: string
19-
arguments: {
20-
query: string
21-
tokensNum: number
22-
}
23-
}
24-
}
25-
26-
interface McpCodeResponse {
27-
jsonrpc: string
28-
result: {
29-
content: Array<{
30-
type: string
31-
text: string
32-
}>
33-
}
34-
}
35-
36-
export const CodeSearchTool = Tool.define("codesearch", {
37-
description: DESCRIPTION,
38-
parameters: z.object({
39-
query: z
40-
.string()
41-
.describe(
42-
"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'",
43-
),
44-
tokensNum: z
45-
.number()
46-
.min(1000)
47-
.max(50000)
48-
.default(5000)
49-
.describe(
50-
"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.",
51-
),
52-
}),
53-
async execute(params, ctx) {
54-
await ctx.ask({
55-
permission: "codesearch",
56-
patterns: [params.query],
57-
always: ["*"],
58-
metadata: {
59-
query: params.query,
60-
tokensNum: params.tokensNum,
61-
},
62-
})
63-
64-
const codeRequest: McpCodeRequest = {
65-
jsonrpc: "2.0",
66-
id: 1,
67-
method: "tools/call",
68-
params: {
69-
name: "get_code_context_exa",
70-
arguments: {
71-
query: params.query,
72-
tokensNum: params.tokensNum || 5000,
73-
},
74-
},
75-
}
9+
export const CodeSearchTool = Tool.defineEffect(
10+
"codesearch",
11+
Effect.gen(function* () {
12+
const http = yield* HttpClient.HttpClient
7613

77-
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
14+
return {
15+
description: DESCRIPTION,
16+
parameters: z.object({
17+
query: z
18+
.string()
19+
.describe(
20+
"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'",
21+
),
22+
tokensNum: z
23+
.number()
24+
.min(1000)
25+
.max(50000)
26+
.default(5000)
27+
.describe(
28+
"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.",
29+
),
30+
}),
31+
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
32+
Effect.gen(function* () {
33+
yield* Effect.promise(() =>
34+
ctx.ask({
35+
permission: "codesearch",
36+
patterns: [params.query],
37+
always: ["*"],
38+
metadata: {
39+
query: params.query,
40+
tokensNum: params.tokensNum,
41+
},
42+
}),
43+
)
7844

79-
try {
80-
const headers: Record<string, string> = {
81-
accept: "application/json, text/event-stream",
82-
"content-type": "application/json",
83-
}
45+
const request = HttpClientRequest.post(URL).pipe(
46+
HttpClientRequest.setHeaders({
47+
accept: "application/json, text/event-stream",
48+
"content-type": "application/json",
49+
}),
50+
HttpClientRequest.bodyJsonUnsafe({
51+
jsonrpc: "2.0",
52+
id: 1,
53+
method: "tools/call",
54+
params: {
55+
name: "get_code_context_exa",
56+
arguments: {
57+
query: params.query,
58+
tokensNum: params.tokensNum || 5000,
59+
},
60+
},
61+
}),
62+
)
8463

85-
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, {
86-
method: "POST",
87-
headers,
88-
body: JSON.stringify(codeRequest),
89-
signal,
90-
})
64+
const response = yield* http.execute(request).pipe(Effect.timeout("30 seconds"))
9165

92-
clearTimeout()
93-
94-
if (!response.ok) {
95-
const errorText = await response.text()
96-
throw new Error(`Code search error (${response.status}): ${errorText}`)
97-
}
66+
if (response.status < 200 || response.status >= 300) {
67+
const errorText = yield* response.text
68+
throw new Error(`Code search error (${response.status}): ${errorText}`)
69+
}
9870

99-
const responseText = await response.text()
71+
const responseText = yield* response.text
10072

101-
// Parse SSE response
102-
const lines = responseText.split("\n")
103-
for (const line of lines) {
104-
if (line.startsWith("data: ")) {
105-
const data: McpCodeResponse = JSON.parse(line.substring(6))
106-
if (data.result && data.result.content && data.result.content.length > 0) {
107-
return {
108-
output: data.result.content[0].text,
109-
title: `Code search: ${params.query}`,
110-
metadata: {},
73+
// Parse SSE response
74+
for (const line of responseText.split("\n")) {
75+
if (line.startsWith("data: ")) {
76+
const data = JSON.parse(line.substring(6))
77+
if (data.result?.content?.[0]?.text) {
78+
return {
79+
output: data.result.content[0].text,
80+
title: `Code search: ${params.query}`,
81+
metadata: {},
82+
}
83+
}
11184
}
11285
}
113-
}
114-
}
115-
116-
return {
117-
output:
118-
"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.",
119-
title: `Code search: ${params.query}`,
120-
metadata: {},
121-
}
122-
} catch (error) {
123-
clearTimeout()
12486

125-
if (error instanceof Error && error.name === "AbortError") {
126-
throw new Error("Code search request timed out")
127-
}
128-
129-
throw error
87+
return {
88+
output:
89+
"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.",
90+
title: `Code search: ${params.query}`,
91+
metadata: {},
92+
}
93+
}).pipe(
94+
Effect.catchTag("TimeoutError", () => Effect.die(new Error("Code search request timed out"))),
95+
Effect.runPromise,
96+
),
13097
}
131-
},
132-
})
98+
}),
99+
)

packages/opencode/src/tool/registry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Glob } from "../util/glob"
2828
import path from "path"
2929
import { pathToFileURL } from "url"
3030
import { Effect, Layer, ServiceMap } from "effect"
31+
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
3132
import { InstanceState } from "@/effect/instance-state"
3233
import { makeRuntime } from "@/effect/run-service"
3334
import { Env } from "../env"
@@ -80,6 +81,7 @@ export namespace ToolRegistry {
8081
| FileTime.Service
8182
| Instruction.Service
8283
| AppFileSystem.Service
84+
| HttpClient.HttpClient
8385
> = Layer.effect(
8486
Service,
8587
Effect.gen(function* () {
@@ -92,6 +94,7 @@ export namespace ToolRegistry {
9294
const read = yield* ReadTool
9395
const question = yield* QuestionTool
9496
const todo = yield* TodoWriteTool
97+
const codesearch = yield* CodeSearchTool
9598

9699
const state = yield* InstanceState.make<State>(
97100
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -160,7 +163,7 @@ export namespace ToolRegistry {
160163
fetch: Tool.init(WebFetchTool),
161164
todo: Tool.init(todo),
162165
search: Tool.init(WebSearchTool),
163-
code: Tool.init(CodeSearchTool),
166+
code: Tool.init(codesearch),
164167
skill: Tool.init(SkillTool),
165168
patch: Tool.init(ApplyPatchTool),
166169
question: Tool.init(question),
@@ -301,6 +304,7 @@ export namespace ToolRegistry {
301304
Layer.provide(FileTime.defaultLayer),
302305
Layer.provide(Instruction.defaultLayer),
303306
Layer.provide(AppFileSystem.defaultLayer),
307+
Layer.provide(FetchHttpClient.layer),
304308
),
305309
)
306310

packages/opencode/test/session/prompt-effect.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NodeFileSystem } from "@effect/platform-node"
2+
import { FetchHttpClient } from "effect/unstable/http"
23
import { expect } from "bun:test"
34
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
45
import path from "path"
@@ -169,6 +170,7 @@ function makeHttp() {
169170
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
170171
const registry = ToolRegistry.layer.pipe(
171172
Layer.provide(Skill.defaultLayer),
173+
Layer.provide(FetchHttpClient.layer),
172174
Layer.provideMerge(todo),
173175
Layer.provideMerge(question),
174176
Layer.provideMerge(deps),

packages/opencode/test/session/snapshot-tool-race.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
5353
import { Truncate } from "../../src/tool/truncate"
5454
import { AppFileSystem } from "../../src/filesystem"
5555
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
56+
import { FetchHttpClient } from "effect/unstable/http"
5657

5758
Log.init({ print: false })
5859

@@ -134,6 +135,7 @@ function makeHttp() {
134135
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
135136
const registry = ToolRegistry.layer.pipe(
136137
Layer.provide(Skill.defaultLayer),
138+
Layer.provide(FetchHttpClient.layer),
137139
Layer.provideMerge(todo),
138140
Layer.provideMerge(question),
139141
Layer.provideMerge(deps),

0 commit comments

Comments
 (0)