refactor(tool): convert webfetch tool internals to Effect (#21809)

This commit is contained in:
Kit Langton
2026-04-10 10:50:13 -04:00
committed by GitHub
parent 42206da1f8
commit e83404367c
6 changed files with 180 additions and 163 deletions
+5 -1
View File
@@ -30,6 +30,7 @@ import { Glob } from "../util/glob"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@@ -84,6 +85,7 @@ export namespace ToolRegistry {
| FileTime.Service | FileTime.Service
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| HttpClient.HttpClient
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -98,6 +100,7 @@ export namespace ToolRegistry {
const todo = yield* TodoWriteTool const todo = yield* TodoWriteTool
const lsptool = yield* LspTool const lsptool = yield* LspTool
const plan = yield* PlanExitTool const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -163,7 +166,7 @@ export namespace ToolRegistry {
edit: Tool.init(EditTool), edit: Tool.init(EditTool),
write: Tool.init(WriteTool), write: Tool.init(WriteTool),
task: Tool.init(task), task: Tool.init(task),
fetch: Tool.init(WebFetchTool), fetch: Tool.init(webfetch),
todo: Tool.init(todo), todo: Tool.init(todo),
search: Tool.init(WebSearchTool), search: Tool.init(WebSearchTool),
code: Tool.init(CodeSearchTool), code: Tool.init(CodeSearchTool),
@@ -309,6 +312,7 @@ export namespace ToolRegistry {
Layer.provide(FileTime.defaultLayer), Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
), ),
) )
+53 -59
View File
@@ -1,31 +1,40 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Tool } from "./tool" import { Tool } from "./tool"
import TurndownService from "turndown" import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt" import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
import { iife } from "@/util/iife"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define("webfetch", { const parameters = z.object({
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"), url: z.string().describe("The URL to fetch content from"),
format: z format: z
.enum(["text", "markdown", "html"]) .enum(["text", "markdown", "html"])
.default("markdown") .default("markdown")
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
}), })
async execute(params, ctx) {
// Validate URL export const WebFetchTool = Tool.defineEffect(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(http)
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://") throw new Error("URL must start with http:// or https://")
} }
await ctx.ask({ yield* Effect.promise(() =>
ctx.ask({
permission: "webfetch", permission: "webfetch",
patterns: [params.url], patterns: [params.url],
always: ["*"], always: ["*"],
@@ -34,23 +43,24 @@ export const WebFetchTool = Tool.define("webfetch", {
format: params.format, format: params.format,
timeout: params.timeout, timeout: params.timeout,
}, },
}) }),
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
// Build Accept header based on requested format with q parameters for fallbacks // Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*" let acceptHeader = "*/*"
switch (params.format) { switch (params.format) {
case "markdown": case "markdown":
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" 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"
break break
case "text": case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break break
case "html": case "html":
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" 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"
break break
default: default:
acceptHeader = acceptHeader =
@@ -63,35 +73,37 @@ export const WebFetchTool = Tool.define("webfetch", {
"Accept-Language": "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
} }
const response = await iife(async () => { const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
try {
const initial = await fetch(params.url, { signal, headers })
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" const response = yield* httpOk.execute(request).pipe(
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) Effect.catchIf(
: initial (err) =>
} finally { err.reason._tag === "StatusCodeError" &&
clearTimeout() err.reason.response.status === 403 &&
} err.reason.response.headers["cf-mitigated"] === "challenge",
}) () =>
httpOk.execute(
if (!response.ok) { HttpClientRequest.get(params.url).pipe(
throw new Error(`Request failed with status code: ${response.status}`) HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
} ),
),
),
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
)
// Check content length // Check content length
const contentLength = response.headers.get("content-length") const contentLength = response.headers["content-length"]
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)") throw new Error("Response too large (exceeds 5MB limit)")
} }
const arrayBuffer = await response.arrayBuffer() const arrayBuffer = yield* response.arrayBuffer
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)") throw new Error("Response too large (exceeds 5MB limit)")
} }
const contentType = response.headers.get("content-type") || "" const contentType = response.headers["content-type"] || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})` const title = `${params.url} (${contentType})`
@@ -106,7 +118,7 @@ export const WebFetchTool = Tool.define("webfetch", {
metadata: {}, metadata: {},
attachments: [ attachments: [
{ {
type: "file", type: "file" as const,
mime, mime,
url: `data:${mime};base64,${base64Content}`, url: `data:${mime};base64,${base64Content}`,
}, },
@@ -127,43 +139,25 @@ export const WebFetchTool = Tool.define("webfetch", {
metadata: {}, metadata: {},
} }
} }
return { return { output: content, title, metadata: {} }
output: content,
title,
metadata: {},
}
case "text": case "text":
if (contentType.includes("text/html")) { if (contentType.includes("text/html")) {
const text = await extractTextFromHTML(content) const text = yield* Effect.promise(() => extractTextFromHTML(content))
return { return { output: text, title, metadata: {} }
output: text,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
} }
return { output: content, title, metadata: {} }
case "html": case "html":
return { return { output: content, title, metadata: {} }
output: content,
title,
metadata: {},
}
default: default:
return { return { output: content, title, metadata: {} }
output: content,
title,
metadata: {},
} }
}).pipe(Effect.runPromise),
} }
}, }),
}) )
async function extractTextFromHTML(html: string) { async function extractTextFromHTML(html: string) {
let text = "" let text = ""
@@ -1,5 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch" import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const tool = await WebFetchTool.init() const tool = await WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())),
Effect.provide(FetchHttpClient.layer),
Effect.runPromise,
)
// Warm up // Warm up
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
@@ -1,6 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
@@ -169,6 +170,7 @@ function makeHttp() {
const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -12,7 +12,8 @@
* tools internally during multi-step processing before emitting events. * tools internally during multi-step processing before emitting events.
*/ */
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Session } from "../../src/session" import { Session } from "../../src/session"
@@ -28,7 +29,6 @@ import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts // Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Layer } from "effect"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Command } from "../../src/command" import { Command } from "../../src/command"
@@ -134,6 +134,7 @@ function makeHttp() {
const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
+13 -3
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch" import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -22,6 +24,14 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url) await fn(server.url)
} }
function initTool() {
return WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())),
Effect.provide(FetchHttpClient.layer),
Effect.runPromise,
)
}
describe("tool.webfetch", () => { describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => { test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
@@ -31,7 +41,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() const webfetch = await initTool()
const result = await webfetch.execute( const result = await webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" }, { url: new URL("/image.png", url).toString(), format: "markdown" },
ctx, ctx,
@@ -63,7 +73,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx) const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
expect(result.output).toContain("<svg") expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
@@ -84,7 +94,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx) const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
expect(result.output).toBe("hello from webfetch") expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()