Skip to content

Commit 91a3eef

Browse files
committed
refactor(tool): convert webfetch 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. Update registry to yield WebFetchTool and provide FetchHttpClient.layer. Fix tests to provide FetchHttpClient.layer where needed.
1 parent 91786d2 commit 91a3eef

6 files changed

Lines changed: 157 additions & 155 deletions

File tree

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 webfetch = yield* WebFetchTool
9598

9699
const state = yield* InstanceState.make<State>(
97100
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -157,7 +160,7 @@ export namespace ToolRegistry {
157160
edit: Tool.init(EditTool),
158161
write: Tool.init(WriteTool),
159162
task: Tool.init(task),
160-
fetch: Tool.init(WebFetchTool),
163+
fetch: Tool.init(webfetch),
161164
todo: Tool.init(todo),
162165
search: Tool.init(WebSearchTool),
163166
code: Tool.init(CodeSearchTool),
@@ -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/src/tool/webfetch.ts

Lines changed: 127 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,192 +1,171 @@
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 TurndownService from "turndown"
46
import DESCRIPTION from "./webfetch.txt"
5-
import { abortAfterAny } from "../util/abort"
6-
import { iife } from "@/util/iife"
77

88
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
99
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
1010
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
1111

12-
export const WebFetchTool = Tool.define("webfetch", {
13-
description: DESCRIPTION,
14-
parameters: z.object({
15-
url: z.string().describe("The URL to fetch content from"),
16-
format: z
17-
.enum(["text", "markdown", "html"])
18-
.default("markdown")
19-
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
20-
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
21-
}),
22-
async execute(params, ctx) {
23-
// Validate URL
24-
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
25-
throw new Error("URL must start with http:// or https://")
26-
}
12+
const parameters = z.object({
13+
url: z.string().describe("The URL to fetch content from"),
14+
format: z
15+
.enum(["text", "markdown", "html"])
16+
.default("markdown")
17+
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
18+
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
19+
})
2720

28-
await ctx.ask({
29-
permission: "webfetch",
30-
patterns: [params.url],
31-
always: ["*"],
32-
metadata: {
33-
url: params.url,
34-
format: params.format,
35-
timeout: params.timeout,
36-
},
37-
})
21+
export const WebFetchTool = Tool.defineEffect(
22+
"webfetch",
23+
Effect.gen(function* () {
24+
const http = yield* HttpClient.HttpClient
25+
26+
return {
27+
description: DESCRIPTION,
28+
parameters,
29+
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context) {
30+
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
31+
throw new Error("URL must start with http:// or https://")
32+
}
3833

39-
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
40-
41-
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
42-
43-
// Build Accept header based on requested format with q parameters for fallbacks
44-
let acceptHeader = "*/*"
45-
switch (params.format) {
46-
case "markdown":
47-
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
48-
break
49-
case "text":
50-
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
51-
break
52-
case "html":
53-
acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
54-
break
55-
default:
56-
acceptHeader =
57-
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
58-
}
59-
const headers = {
60-
"User-Agent":
61-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
62-
Accept: acceptHeader,
63-
"Accept-Language": "en-US,en;q=0.9",
64-
}
34+
await ctx.ask({
35+
permission: "webfetch",
36+
patterns: [params.url],
37+
always: ["*"],
38+
metadata: {
39+
url: params.url,
40+
format: params.format,
41+
timeout: params.timeout,
42+
},
43+
})
44+
45+
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
46+
47+
let accept = "*/*"
48+
switch (params.format) {
49+
case "markdown":
50+
accept = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
51+
break
52+
case "text":
53+
accept = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
54+
break
55+
case "html":
56+
accept = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
57+
break
58+
default:
59+
accept =
60+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
61+
}
62+
const headers = {
63+
"User-Agent":
64+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
65+
Accept: accept,
66+
"Accept-Language": "en-US,en;q=0.9",
67+
}
6568

66-
const response = await iife(async () => {
67-
try {
68-
const initial = await fetch(params.url, { signal, headers })
69-
70-
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
71-
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
72-
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
73-
: initial
74-
} finally {
75-
clearTimeout()
76-
}
77-
})
69+
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
7870

79-
if (!response.ok) {
80-
throw new Error(`Request failed with status code: ${response.status}`)
81-
}
71+
const program = Effect.gen(function* () {
72+
const initial = yield* http.execute(request)
8273

83-
// Check content length
84-
const contentLength = response.headers.get("content-length")
85-
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
86-
throw new Error("Response too large (exceeds 5MB limit)")
87-
}
74+
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
75+
const response =
76+
initial.status === 403 && initial.headers["cf-mitigated"] === "challenge"
77+
? yield* http.execute(
78+
HttpClientRequest.get(params.url).pipe(
79+
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
80+
),
81+
)
82+
: initial
8883

89-
const arrayBuffer = await response.arrayBuffer()
90-
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
91-
throw new Error("Response too large (exceeds 5MB limit)")
92-
}
84+
if (response.status < 200 || response.status >= 300) {
85+
throw new Error(`Request failed with status code: ${response.status}`)
86+
}
9387

94-
const contentType = response.headers.get("content-type") || ""
95-
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
96-
const title = `${params.url} (${contentType})`
97-
98-
// Check if response is an image
99-
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
100-
101-
if (isImage) {
102-
const base64Content = Buffer.from(arrayBuffer).toString("base64")
103-
return {
104-
title,
105-
output: "Image fetched successfully",
106-
metadata: {},
107-
attachments: [
108-
{
109-
type: "file",
110-
mime,
111-
url: `data:${mime};base64,${base64Content}`,
112-
},
113-
],
114-
}
115-
}
88+
const contentLength = response.headers["content-length"]
89+
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
90+
throw new Error("Response too large (exceeds 5MB limit)")
91+
}
11692

117-
const content = new TextDecoder().decode(arrayBuffer)
118-
119-
// Handle content based on requested format and actual content type
120-
switch (params.format) {
121-
case "markdown":
122-
if (contentType.includes("text/html")) {
123-
const markdown = convertHTMLToMarkdown(content)
124-
return {
125-
output: markdown,
126-
title,
127-
metadata: {},
93+
const arrayBuffer = yield* response.arrayBuffer
94+
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
95+
throw new Error("Response too large (exceeds 5MB limit)")
12896
}
129-
}
130-
return {
131-
output: content,
132-
title,
133-
metadata: {},
134-
}
13597

136-
case "text":
137-
if (contentType.includes("text/html")) {
138-
const text = await extractTextFromHTML(content)
139-
return {
140-
output: text,
141-
title,
142-
metadata: {},
98+
const contentType = response.headers["content-type"] || ""
99+
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
100+
const title = `${params.url} (${contentType})`
101+
102+
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
103+
104+
if (isImage) {
105+
return {
106+
title,
107+
output: "Image fetched successfully",
108+
metadata: {},
109+
attachments: [
110+
{
111+
type: "file" as const,
112+
mime,
113+
url: `data:${mime};base64,${Buffer.from(arrayBuffer).toString("base64")}`,
114+
},
115+
],
116+
}
143117
}
144-
}
145-
return {
146-
output: content,
147-
title,
148-
metadata: {},
149-
}
150118

151-
case "html":
152-
return {
153-
output: content,
154-
title,
155-
metadata: {},
156-
}
119+
const content = new TextDecoder().decode(arrayBuffer)
157120

158-
default:
159-
return {
160-
output: content,
161-
title,
162-
metadata: {},
163-
}
121+
switch (params.format) {
122+
case "markdown":
123+
if (contentType.includes("text/html")) {
124+
return { output: convertHTMLToMarkdown(content), title, metadata: {} }
125+
}
126+
return { output: content, title, metadata: {} }
127+
128+
case "text":
129+
if (contentType.includes("text/html")) {
130+
return { output: yield* Effect.promise(() => extractTextFromHTML(content)), title, metadata: {} }
131+
}
132+
return { output: content, title, metadata: {} }
133+
134+
case "html":
135+
return { output: content, title, metadata: {} }
136+
137+
default:
138+
return { output: content, title, metadata: {} }
139+
}
140+
}).pipe(Effect.timeout(timeout), Effect.catchTag("TimeoutError", () => Effect.die("Request timed out")))
141+
142+
return await Effect.runPromise(program)
143+
},
164144
}
165-
},
166-
})
145+
}),
146+
)
167147

168148
async function extractTextFromHTML(html: string) {
169149
let text = ""
170-
let skipContent = false
150+
let skip = false
171151

172152
const rewriter = new HTMLRewriter()
173153
.on("script, style, noscript, iframe, object, embed", {
174154
element() {
175-
skipContent = true
155+
skip = true
176156
},
177157
text() {
178158
// Skip text content inside these elements
179159
},
180160
})
181161
.on("*", {
182162
element(element) {
183-
// Reset skip flag when entering other elements
184163
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
185-
skipContent = false
164+
skip = false
186165
}
187166
},
188167
text(input) {
189-
if (!skipContent) {
168+
if (!skip) {
190169
text += input.text
191170
}
192171
},

packages/opencode/test/memory/abort-leak.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, test, expect } from "bun:test"
22
import path from "path"
3+
import { Effect } from "effect"
4+
import { FetchHttpClient } from "effect/unstable/http"
35
import { Instance } from "../../src/project/instance"
46
import { WebFetchTool } from "../../src/tool/webfetch"
57
import { SessionID, MessageID } from "../../src/session/schema"
@@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => {
3032
await Instance.provide({
3133
directory: projectRoot,
3234
fn: async () => {
33-
const tool = await WebFetchTool.init()
35+
const tool = await WebFetchTool.pipe(
36+
Effect.flatMap((info) => Effect.promise(() => info.init())),
37+
Effect.provide(FetchHttpClient.layer),
38+
Effect.runPromise,
39+
)
3440

3541
// Warm up
3642
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NodeFileSystem } from "@effect/platform-node"
22
import { expect } from "bun:test"
33
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
4+
import { FetchHttpClient } from "effect/unstable/http"
45
import path from "path"
56
import z from "zod"
67
import { Agent as AgentSvc } from "../../src/agent/agent"
@@ -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),

0 commit comments

Comments
 (0)