refactor(core): unify v2 tool architecture (#31168)

This commit is contained in:
Kit Langton 2026-06-06 20:49:12 -04:00 committed by GitHub
commit 660a00d317
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2297 additions and 2041 deletions

View file

@ -1,12 +1,13 @@
export * as WebFetchTool from "./webfetch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@ -34,8 +35,6 @@ const Success = Schema.Struct({
contentType: Schema.String,
format: Parameters.fields.format,
output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
})
type Format = (typeof Parameters.Type)["format"]
@ -49,6 +48,7 @@ const acceptHeader = (format: Format) => {
case "html":
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
}
return "*/*"
}
const headers = (format: Format, userAgent: string) => ({
@ -89,15 +89,17 @@ const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
}
const chunks: Uint8Array[] = []
let size = 0
yield* Stream.runForEach(response.stream, (chunk) =>
Effect.sync(() => {
Effect.gen(function* () {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
if (size > MAX_RESPONSE_BYTES)
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
chunks.push(chunk)
return undefined
}),
)
return Buffer.concat(chunks, size)
@ -115,9 +117,6 @@ const isTextualMime = (mime: string) =>
mime.endsWith("+xml") ||
mime === "application/javascript" ||
mime === "application/x-javascript"
const outputMime = (format: Format) =>
format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain"
const convert = (content: string, contentType: string, format: Format) => {
if (!contentType.includes("text/html")) return content
if (format === "markdown") return convertHTMLToMarkdown(content)
@ -125,71 +124,64 @@ const convert = (content: string, contentType: string, format: Format) => {
return content
}
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const http = yield* HttpClient.HttpClient
const resources = yield* ToolOutputStore.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const parsed = new URL(parameters.url)
assertHttpUrl(parsed)
yield* tools
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => assertHttpUrl(new URL(input.url)),
catch: (error) => error,
})
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
yield* permission.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, parameters.url, parameters.format).pipe(
Effect.catchIf(isCloudflareChallenge, () =>
execute(http, parameters.url, parameters.format, "opencode"),
),
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, input.url, input.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime))
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
if (!isTextualMime(mime))
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
return { body: yield* collectBody(response), contentType }
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
}),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`)
if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`)
return { body: yield* collectBody(response), contentType }
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.die(new Error("Request timed out")),
}),
)
const content = convert(new TextDecoder().decode(body), contentType, parameters.format)
const truncated = yield* resources.truncate({
sessionID,
toolCallID: call.id,
content,
mime: outputMime(parameters.format),
})
return {
url: parameters.url,
contentType,
format: parameters.format,
output: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
),
),
),
}),
)
const content = convert(new TextDecoder().decode(body), contentType, input.format)
return {
url: input.url,
contentType,
format: input.format,
output: content,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}),
})
.pipe(Effect.orDie)
}),
)