webfetch.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. export * as WebFetchTool from "./webfetch"
  2. import { ToolFailure } from "@opencode-ai/llm"
  3. import { Duration, Effect, Layer, Schema, Stream } from "effect"
  4. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  5. import { Parser } from "htmlparser2"
  6. import TurndownService from "turndown"
  7. import { PermissionV2 } from "../permission"
  8. import { Tool } from "./tool"
  9. import { Tools } from "./tools"
  10. export const name = "webfetch"
  11. export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
  12. export const DEFAULT_TIMEOUT_SECONDS = 30
  13. export const MAX_TIMEOUT_SECONDS = 120
  14. export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
  15. Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
  16. const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
  17. export const Input = Schema.Struct({
  18. url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
  19. format: Schema.Literals(["text", "markdown", "html"])
  20. .annotate({ description: "The format to return the content in. Defaults to markdown." })
  21. .pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
  22. timeout: Timeout.pipe(Schema.optional).annotate({
  23. description: `Optional timeout in seconds (maximum: ${MAX_TIMEOUT_SECONDS})`,
  24. }),
  25. })
  26. const Output = Schema.Struct({
  27. url: Schema.String,
  28. contentType: Schema.String,
  29. format: Input.fields.format,
  30. output: Schema.String,
  31. })
  32. type Format = (typeof Input.Type)["format"]
  33. const acceptHeader = (format: Format) => {
  34. switch (format) {
  35. case "markdown":
  36. return "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
  37. case "text":
  38. return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
  39. case "html":
  40. 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"
  41. }
  42. return "*/*"
  43. }
  44. const headers = (format: Format, userAgent: string) => ({
  45. "User-Agent": userAgent,
  46. Accept: acceptHeader(format),
  47. "Accept-Language": "en-US,en;q=0.9",
  48. })
  49. const browserUserAgent =
  50. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
  51. const isCloudflareChallenge = (error: unknown) => {
  52. if (!error || typeof error !== "object" || !("reason" in error)) return false
  53. const reason = error.reason
  54. if (
  55. !reason ||
  56. typeof reason !== "object" ||
  57. !("_tag" in reason) ||
  58. reason._tag !== "StatusCodeError" ||
  59. !("response" in reason)
  60. )
  61. return false
  62. const response = reason.response as HttpClientResponse.HttpClientResponse
  63. return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
  64. }
  65. const request = (url: string, format: Format, userAgent = browserUserAgent) =>
  66. HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
  67. const assertHttpUrl = (url: URL) => {
  68. if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
  69. }
  70. const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
  71. http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
  72. const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
  73. Effect.gen(function* () {
  74. const contentLength = response.headers["content-length"]
  75. if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
  76. return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
  77. }
  78. const chunks: Uint8Array[] = []
  79. let size = 0
  80. yield* Stream.runForEach(response.stream, (chunk) =>
  81. Effect.gen(function* () {
  82. size += chunk.byteLength
  83. if (size > MAX_RESPONSE_BYTES)
  84. return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
  85. chunks.push(chunk)
  86. return undefined
  87. }),
  88. )
  89. return Buffer.concat(chunks, size)
  90. })
  91. const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
  92. const isImageAttachment = (mime: string) =>
  93. mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
  94. const isTextualMime = (mime: string) =>
  95. !mime ||
  96. mime.startsWith("text/") ||
  97. mime === "application/json" ||
  98. mime.endsWith("+json") ||
  99. mime === "application/xml" ||
  100. mime.endsWith("+xml") ||
  101. mime === "application/javascript" ||
  102. mime === "application/x-javascript"
  103. const convert = (content: string, contentType: string, format: Format) => {
  104. if (!contentType.includes("text/html")) return content
  105. if (format === "markdown") return convertHTMLToMarkdown(content)
  106. if (format === "text") return extractTextFromHTML(content)
  107. return content
  108. }
  109. export const layer = Layer.effectDiscard(
  110. Effect.gen(function* () {
  111. const tools = yield* Tools.Service
  112. const http = yield* HttpClient.HttpClient
  113. const permission = yield* PermissionV2.Service
  114. yield* tools
  115. .register({
  116. [name]: Tool.make({
  117. description,
  118. input: Input,
  119. output: Output,
  120. toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
  121. execute: (input, context) =>
  122. Effect.gen(function* () {
  123. yield* Effect.try({
  124. try: () => assertHttpUrl(new URL(input.url)),
  125. catch: (error) => error,
  126. })
  127. yield* permission.assert({
  128. action: name,
  129. resources: [input.url],
  130. save: ["*"],
  131. metadata: input,
  132. sessionID: context.sessionID,
  133. agent: context.agent,
  134. source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
  135. })
  136. const { body, contentType } = yield* Effect.gen(function* () {
  137. const response = yield* execute(http, input.url, input.format).pipe(
  138. Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
  139. )
  140. const contentType = response.headers["content-type"] || ""
  141. const mime = mimeFrom(contentType)
  142. if (isImageAttachment(mime))
  143. return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
  144. if (!isTextualMime(mime))
  145. return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
  146. return { body: yield* collectBody(response), contentType }
  147. }).pipe(
  148. Effect.timeoutOrElse({
  149. duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
  150. orElse: () => Effect.fail(new Error("Request timed out")),
  151. }),
  152. )
  153. const content = convert(new TextDecoder().decode(body), contentType, input.format)
  154. return {
  155. url: input.url,
  156. contentType,
  157. format: input.format,
  158. output: content,
  159. }
  160. }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
  161. }),
  162. })
  163. .pipe(Effect.orDie)
  164. }),
  165. )
  166. export function extractTextFromHTML(html: string) {
  167. let text = ""
  168. let skipDepth = 0
  169. const parser = new Parser({
  170. onopentag(name) {
  171. if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
  172. },
  173. ontext(input) {
  174. if (skipDepth === 0) text += input
  175. },
  176. onclosetag() {
  177. if (skipDepth > 0) skipDepth--
  178. },
  179. })
  180. parser.write(html)
  181. parser.end()
  182. return text.trim()
  183. }
  184. export function convertHTMLToMarkdown(html: string) {
  185. const turndown = new TurndownService({
  186. headingStyle: "atx",
  187. hr: "---",
  188. bulletListMarker: "-",
  189. codeBlockStyle: "fenced",
  190. emDelimiter: "*",
  191. })
  192. turndown.remove(["script", "style", "meta", "link"])
  193. return turndown.turndown(html)
  194. }