Skip to content

Commit 21827b1

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 46b74e0 commit 21827b1

3 files changed

Lines changed: 249 additions & 124 deletions

File tree

Lines changed: 56 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,65 @@
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 "./codesearch.txt"
4-
import { abortAfterAny } from "../util/abort"
57

6-
const API_CONFIG = {
7-
BASE_URL: "https://mcp.exa.ai",
8-
ENDPOINTS: {
9-
CONTEXT: "/mcp",
10-
},
11-
} as const
8+
export const CodeSearchTool = Tool.defineEffect(
9+
"codesearch",
10+
Effect.gen(function* () {
11+
const http = yield* HttpClient.HttpClient
1212

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

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-
})
44+
const result = yield* McpExa.call(
45+
http,
46+
"get_code_context_exa",
47+
McpExa.CodeArgs,
48+
{
49+
query: params.query,
50+
tokensNum: params.tokensNum || 5000,
51+
},
52+
"30 seconds",
53+
)
9154

92-
clearTimeout()
93-
94-
if (!response.ok) {
95-
const errorText = await response.text()
96-
throw new Error(`Code search error (${response.status}): ${errorText}`)
97-
}
98-
99-
const responseText = await response.text()
100-
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: {},
111-
}
55+
return {
56+
output:
57+
result ??
58+
"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.",
59+
title: `Code search: ${params.query}`,
60+
metadata: {},
11261
}
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()
124-
125-
if (error instanceof Error && error.name === "AbortError") {
126-
throw new Error("Code search request timed out")
127-
}
128-
129-
throw error
62+
}).pipe(Effect.runPromise),
13063
}
131-
},
132-
})
64+
}),
65+
)

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export namespace ToolRegistry {
102102
const plan = yield* PlanExitTool
103103
const webfetch = yield* WebFetchTool
104104
const websearch = yield* WebSearchTool
105+
const codesearch = yield* CodeSearchTool
105106

106107
const state = yield* InstanceState.make<State>(
107108
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -170,7 +171,7 @@ export namespace ToolRegistry {
170171
fetch: Tool.init(webfetch),
171172
todo: Tool.init(todo),
172173
search: Tool.init(websearch),
173-
code: Tool.init(CodeSearchTool),
174+
code: Tool.init(codesearch),
174175
skill: Tool.init(SkillTool),
175176
patch: Tool.init(ApplyPatchTool),
176177
question: Tool.init(question),

specs/task-tool-cycle-options.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Task Tool Cycle
2+
3+
This document explains the `task` tool cycle, the affected callers, and the three most realistic ways to fix it.
4+
5+
## Problem
6+
7+
The current dependency loop is:
8+
9+
```mermaid
10+
flowchart LR
11+
SP[SessionPrompt] --> TR[ToolRegistry]
12+
TR --> TT[TaskTool]
13+
TT --> SP
14+
```
15+
16+
`SessionPrompt` needs the registry to resolve tools. The registry registers `TaskTool`. `TaskTool` calls back into `SessionPrompt` for cancel, prompt-part resolution, and prompt execution.
17+
18+
```coderef
19+
title: TaskTool calls SessionPrompt.cancel / resolvePromptParts / prompt
20+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/tool/task.ts
21+
lines: 116-145
22+
lang: ts
23+
```
24+
25+
```coderef
26+
title: ToolRegistry captures TaskTool in state as a named tool
27+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/tool/registry.ts
28+
lines: 47-61
29+
lang: ts
30+
```
31+
32+
```coderef
33+
title: ToolRegistry initializes TaskTool and includes it in the builtin tool list
34+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/tool/registry.ts
35+
lines: 91-97
36+
lang: ts
37+
```
38+
39+
```coderef
40+
title: ToolRegistry exposes task as both a builtin tool and a named tool
41+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/tool/registry.ts
42+
lines: 174-195
43+
lang: ts
44+
```
45+
46+
```coderef
47+
title: SessionPrompt gets model-visible tools from ToolRegistry
48+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/session/prompt.ts
49+
lines: 389-431
50+
lang: ts
51+
```
52+
53+
```coderef
54+
title: SessionPrompt pulls the named task tool during subtask handling
55+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/session/prompt.ts
56+
lines: 522-525
57+
lang: ts
58+
```
59+
60+
```coderef
61+
title: SessionPrompt executes the named task tool during subtask handling
62+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/session/prompt.ts
63+
lines: 580-617
64+
lang: ts
65+
```
66+
67+
## Audit
68+
69+
If `task` leaves `ToolRegistry`, the main affected areas are:
70+
71+
1. `SessionPrompt.resolveTools(...)`
72+
2. `SessionPrompt.handleSubtask(...)`
73+
3. `server/routes/experimental.ts` tool listing endpoints
74+
4. `cli/cmd/debug/agent.ts` available-tool inspection
75+
5. task-related tests
76+
77+
```coderef
78+
title: Experimental routes read tool ids and tool definitions from ToolRegistry
79+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/server/routes/experimental.ts
80+
lines: 136-195
81+
lang: ts
82+
```
83+
84+
```coderef
85+
title: Debug CLI asks ToolRegistry for available tools
86+
file: /Users/kit/code/open-source/opencode/packages/opencode/src/cli/cmd/debug/agent.ts
87+
lines: 72-89
88+
lang: ts
89+
```
90+
91+
## Ranked Solutions
92+
93+
### 1. Best overall: split base tools from prompt-recursive tools
94+
95+
This is the cleanest architecture.
96+
97+
Idea:
98+
99+
1. `ToolRegistry` becomes a registry of ordinary tools only.
100+
2. A prompt-side composition layer adds recursive prompt tools like `task`.
101+
3. A shared `SubtaskRunner` owns child-session orchestration used by both prompt and task execution.
102+
103+
Target shape:
104+
105+
```mermaid
106+
flowchart LR
107+
SP[SessionPrompt] --> BR[Base ToolRegistry]
108+
SP --> ST[SubtaskRunner]
109+
TT[Task tool adapter] --> ST
110+
```
111+
112+
Why it is best:
113+
114+
- registry becomes conceptually clean again
115+
- recursive prompt behavior lives near prompt code
116+
- shared subtask logic has one owner
117+
- best long-term path for removing remaining facades
118+
119+
Downside:
120+
121+
- medium/large refactor
122+
- requires new modules and test rewiring
123+
124+
### 2. Simplest tactical fix: move `task` fully out of ToolRegistry
125+
126+
This is the smallest correct cycle break.
127+
128+
Idea:
129+
130+
- `ToolRegistry` stops registering `task`
131+
- `SessionPrompt` constructs the `task` tool locally
132+
- `SessionPrompt` no longer asks `registry.named().task`
133+
134+
Why it works:
135+
136+
```mermaid
137+
flowchart LR
138+
SP[SessionPrompt] --> TR[ToolRegistry]
139+
SP --> TT[Prompt-local task tool]
140+
```
141+
142+
No back-edge remains.
143+
144+
Downsides:
145+
146+
- `ToolRegistry` is no longer the single source of truth for tools
147+
- registry-based introspection endpoints and debug commands stop seeing `task` unless special-cased
148+
- prompt gains one explicit special-case
149+
150+
### 3. Adapter service or injected prompt callbacks
151+
152+
This is workable but less beautiful.
153+
154+
Idea:
155+
156+
- `TaskTool` depends on a smaller prompt-facing interface such as `TaskPrompt`
157+
- or the task executor gets `prompt`, `resolvePromptParts`, and `cancel` injected
158+
159+
This only breaks the cycle if the new service is truly lower-level than both `TaskTool` and `SessionPrompt`.
160+
161+
Bad version:
162+
163+
```mermaid
164+
flowchart LR
165+
SP[SessionPrompt] --> TR[ToolRegistry]
166+
TR --> TT[TaskTool]
167+
TT --> TP[TaskPrompt]
168+
TP --> SP
169+
```
170+
171+
That just renames the cycle.
172+
173+
Why this ranks lower:
174+
175+
- easy to move the cycle instead of removing it
176+
- more abstraction than clarity for one problematic tool
177+
- tends to introduce hidden wiring or service-locator style patterns
178+
179+
## Recommendation
180+
181+
If the goal is the most beautiful end state, choose **Option 1**.
182+
183+
If the goal is the smallest correct near-term fix, choose **Option 2**.
184+
185+
Option 3 is only attractive if we are prepared to build a real lower-level subtask/prompt-running service rather than a thin wrapper over `SessionPrompt`.
186+
187+
## Practical decision guide
188+
189+
- Want the cleanest architecture: do Option 1
190+
- Want the quickest safe cycle break: do Option 2
191+
- Want to preserve registry ownership without a deeper refactor: Option 3 is risky and likely not worth it

0 commit comments

Comments
 (0)