(args: z.infer<typeof WebFetchArgs>, ctx: ToolContext)
| 173 | argsSchema = WebFetchArgs; |
| 174 | |
| 175 | async execute(args: z.infer<typeof WebFetchArgs>, ctx: ToolContext): Promise<ToolResult> { |
| 176 | if (isPrivateOrLocal(args.url)) { |
| 177 | return { |
| 178 | content: `[WEB_FETCH_BLOCKED] ${args.url} is a private/local address. Use browser_navigate if this is your own dev server, or dev_server_log to read its output.`, |
| 179 | isError: true, |
| 180 | }; |
| 181 | } |
| 182 | |
| 183 | const controller = new AbortController(); |
| 184 | const timer = setTimeout(() => controller.abort(), args.timeout_ms ?? 30_000); |
| 185 | |
| 186 | try { |
| 187 | const response = await proxyFetch(args.url, { |
| 188 | signal: ctx.signal ? mergeAbortSignals(ctx.signal, controller.signal) : controller.signal, |
| 189 | redirect: 'follow', |
| 190 | headers: { |
| 191 | 'User-Agent': args.user_agent ?? DEFAULT_UA, |
| 192 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| 193 | 'Accept-Language': 'en-US,en;q=0.9', |
| 194 | }, |
| 195 | }); |
| 196 | |
| 197 | if (!response.ok) { |
| 198 | return { |
| 199 | content: `[WEB_FETCH_ERROR] HTTP ${response.status} ${response.statusText} for ${args.url}`, |
| 200 | isError: true, |
| 201 | }; |
| 202 | } |
| 203 | |
| 204 | const contentType = response.headers.get('content-type') ?? ''; |
| 205 | |
| 206 | // Guard against binary payloads. Reading an image/PDF/font/archive as text |
| 207 | // dumps garbage bytes into the model's context (and burns tokens). Detect |
| 208 | // by content-type and bail with a useful message instead of the raw bytes. |
| 209 | const isBinary = /^(image|audio|video|font)\//i.test(contentType) |
| 210 | || /(application\/(octet-stream|pdf|zip|gzip|x-tar|x-7z|wasm|x-protobuf)|application\/[^;]*\+(zip|octet))/i.test(contentType); |
| 211 | if (isBinary) { |
| 212 | const lenHeader = response.headers.get('content-length'); |
| 213 | return { |
| 214 | content: `URL: ${response.url}\nStatus: ${response.status}\nContent-Type: ${contentType}\n\n[binary content — not fetched as text]\n` + |
| 215 | `This is a binary file (${contentType}${lenHeader ? `, ${lenHeader} bytes` : ''}). web_fetch returns text only; ` + |
| 216 | `it won't read images, PDFs, fonts, or archives. If you need to analyze an image, note its URL and describe what you need — don't re-fetch it.`, |
| 217 | metadata: { url: response.url, status: response.status, contentType, binary: true }, |
| 218 | }; |
| 219 | } |
| 220 | |
| 221 | const body = await response.text(); |
| 222 | const format = args.format ?? 'text'; |
| 223 | const maxChars = args.max_chars ?? 25_000; |
| 224 | |
| 225 | let output: string; |
| 226 | if (format === 'html' || !contentType.includes('html')) { |
| 227 | output = body; |
| 228 | } else if (format === 'markdown') { |
| 229 | output = stripBase64Images(htmlToMarkdown(body)); // drop base64 token-bombs; keep http image links |
| 230 | } else { |
| 231 | output = htmlToText(body); |
| 232 | } |
nothing calls this directly
no test coverage detected