Skip to content

Commit 46b74e0

Browse files
authored
refactor(tool): convert websearch tool internals to Effect (#21810)
1 parent aedc4e9 commit 46b74e0

4 files changed

Lines changed: 129 additions & 128 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Duration, Effect, Schema } from "effect"
2+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3+
4+
const URL = "https://mcp.exa.ai/mcp"
5+
6+
const McpResult = Schema.Struct({
7+
result: Schema.Struct({
8+
content: Schema.Array(
9+
Schema.Struct({
10+
type: Schema.String,
11+
text: Schema.String,
12+
}),
13+
),
14+
}),
15+
})
16+
17+
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
18+
19+
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
20+
for (const line of body.split("\n")) {
21+
if (!line.startsWith("data: ")) continue
22+
const data = yield* decode(line.substring(6))
23+
if (data.result.content[0]?.text) return data.result.content[0].text
24+
}
25+
return undefined
26+
})
27+
28+
export const SearchArgs = Schema.Struct({
29+
query: Schema.String,
30+
type: Schema.String,
31+
numResults: Schema.Number,
32+
livecrawl: Schema.String,
33+
contextMaxCharacters: Schema.optional(Schema.Number),
34+
})
35+
36+
export const CodeArgs = Schema.Struct({
37+
query: Schema.String,
38+
tokensNum: Schema.Number,
39+
})
40+
41+
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
42+
Schema.Struct({
43+
jsonrpc: Schema.Literal("2.0"),
44+
id: Schema.Literal(1),
45+
method: Schema.Literal("tools/call"),
46+
params: Schema.Struct({
47+
name: Schema.String,
48+
arguments: args,
49+
}),
50+
})
51+
52+
export const call = <F extends Schema.Struct.Fields>(
53+
http: HttpClient.HttpClient,
54+
tool: string,
55+
args: Schema.Struct<F>,
56+
value: Schema.Struct.Type<F>,
57+
timeout: Duration.Input,
58+
) =>
59+
Effect.gen(function* () {
60+
const request = yield* HttpClientRequest.post(URL).pipe(
61+
HttpClientRequest.accept("application/json, text/event-stream"),
62+
HttpClientRequest.schemaBodyJson(McpRequest(args))({
63+
jsonrpc: "2.0" as const,
64+
id: 1 as const,
65+
method: "tools/call" as const,
66+
params: { name: tool, arguments: value },
67+
}),
68+
)
69+
const response = yield* HttpClient.filterStatusOk(http).execute(request).pipe(
70+
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
71+
)
72+
const body = yield* response.text
73+
return yield* parseSse(body)
74+
})

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export namespace ToolRegistry {
101101
const lsptool = yield* LspTool
102102
const plan = yield* PlanExitTool
103103
const webfetch = yield* WebFetchTool
104+
const websearch = yield* WebSearchTool
104105

105106
const state = yield* InstanceState.make<State>(
106107
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -168,7 +169,7 @@ export namespace ToolRegistry {
168169
task: Tool.init(task),
169170
fetch: Tool.init(webfetch),
170171
todo: Tool.init(todo),
171-
search: Tool.init(WebSearchTool),
172+
search: Tool.init(websearch),
172173
code: Tool.init(CodeSearchTool),
173174
skill: Tool.init(SkillTool),
174175
patch: Tool.init(ApplyPatchTool),
Lines changed: 52 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
import z from "zod"
2+
import { Effect } from "effect"
3+
import { HttpClient } from "effect/unstable/http"
24
import { Tool } from "./tool"
5+
import * as McpExa from "./mcp-exa"
36
import DESCRIPTION from "./websearch.txt"
4-
import { abortAfterAny } from "../util/abort"
5-
6-
const API_CONFIG = {
7-
BASE_URL: "https://mcp.exa.ai",
8-
ENDPOINTS: {
9-
SEARCH: "/mcp",
10-
},
11-
DEFAULT_NUM_RESULTS: 8,
12-
} as const
137

148
const Parameters = z.object({
159
query: z.string().describe("Websearch query"),
@@ -30,121 +24,53 @@ const Parameters = z.object({
3024
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
3125
})
3226

33-
interface McpSearchRequest {
34-
jsonrpc: string
35-
id: number
36-
method: string
37-
params: {
38-
name: string
39-
arguments: {
40-
query: string
41-
numResults?: number
42-
livecrawl?: "fallback" | "preferred"
43-
type?: "auto" | "fast" | "deep"
44-
contextMaxCharacters?: number
45-
}
46-
}
47-
}
48-
49-
interface McpSearchResponse {
50-
jsonrpc: string
51-
result: {
52-
content: Array<{
53-
type: string
54-
text: string
55-
}>
56-
}
57-
}
58-
59-
export const WebSearchTool = Tool.define("websearch", async () => {
60-
return {
61-
get description() {
62-
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
63-
},
64-
parameters: Parameters,
65-
async execute(params, ctx) {
66-
await ctx.ask({
67-
permission: "websearch",
68-
patterns: [params.query],
69-
always: ["*"],
70-
metadata: {
71-
query: params.query,
72-
numResults: params.numResults,
73-
livecrawl: params.livecrawl,
74-
type: params.type,
75-
contextMaxCharacters: params.contextMaxCharacters,
76-
},
77-
})
78-
79-
const searchRequest: McpSearchRequest = {
80-
jsonrpc: "2.0",
81-
id: 1,
82-
method: "tools/call",
83-
params: {
84-
name: "web_search_exa",
85-
arguments: {
86-
query: params.query,
87-
type: params.type || "auto",
88-
numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS,
89-
livecrawl: params.livecrawl || "fallback",
90-
contextMaxCharacters: params.contextMaxCharacters,
91-
},
92-
},
93-
}
94-
95-
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
96-
97-
try {
98-
const headers: Record<string, string> = {
99-
accept: "application/json, text/event-stream",
100-
"content-type": "application/json",
101-
}
102-
103-
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, {
104-
method: "POST",
105-
headers,
106-
body: JSON.stringify(searchRequest),
107-
signal,
108-
})
109-
110-
clearTimeout()
111-
112-
if (!response.ok) {
113-
const errorText = await response.text()
114-
throw new Error(`Search error (${response.status}): ${errorText}`)
115-
}
116-
117-
const responseText = await response.text()
118-
119-
// Parse SSE response
120-
const lines = responseText.split("\n")
121-
for (const line of lines) {
122-
if (line.startsWith("data: ")) {
123-
const data: McpSearchResponse = JSON.parse(line.substring(6))
124-
if (data.result && data.result.content && data.result.content.length > 0) {
125-
return {
126-
output: data.result.content[0].text,
127-
title: `Web search: ${params.query}`,
128-
metadata: {},
129-
}
130-
}
27+
export const WebSearchTool = Tool.defineEffect(
28+
"websearch",
29+
Effect.gen(function* () {
30+
const http = yield* HttpClient.HttpClient
31+
32+
return {
33+
get description() {
34+
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
35+
},
36+
parameters: Parameters,
37+
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
38+
Effect.gen(function* () {
39+
yield* Effect.promise(() =>
40+
ctx.ask({
41+
permission: "websearch",
42+
patterns: [params.query],
43+
always: ["*"],
44+
metadata: {
45+
query: params.query,
46+
numResults: params.numResults,
47+
livecrawl: params.livecrawl,
48+
type: params.type,
49+
contextMaxCharacters: params.contextMaxCharacters,
50+
},
51+
}),
52+
)
53+
54+
const result = yield* McpExa.call(
55+
http,
56+
"web_search_exa",
57+
McpExa.SearchArgs,
58+
{
59+
query: params.query,
60+
type: params.type || "auto",
61+
numResults: params.numResults || 8,
62+
livecrawl: params.livecrawl || "fallback",
63+
contextMaxCharacters: params.contextMaxCharacters,
64+
},
65+
"25 seconds",
66+
)
67+
68+
return {
69+
output: result ?? "No search results found. Please try a different query.",
70+
title: `Web search: ${params.query}`,
71+
metadata: {},
13172
}
132-
}
133-
134-
return {
135-
output: "No search results found. Please try a different query.",
136-
title: `Web search: ${params.query}`,
137-
metadata: {},
138-
}
139-
} catch (error) {
140-
clearTimeout()
141-
142-
if (error instanceof Error && error.name === "AbortError") {
143-
throw new Error("Search request timed out")
144-
}
145-
146-
throw error
147-
}
148-
},
149-
}
150-
})
73+
}).pipe(Effect.runPromise),
74+
}
75+
}),
76+
)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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"
4-
import { FetchHttpClient } from "effect/unstable/http"
55
import path from "path"
66
import z from "zod"
77
import { Agent as AgentSvc } from "../../src/agent/agent"

0 commit comments

Comments
 (0)