/** * WebFetch tool — fetch a URL and return readable text (audit D5-9). * * Hand-rolled, no deps: global fetch with a hard timeout, http(s)-only, * naive HTML→text strip, capped output. There is deliberately NO WebSearch * counterpart — claude's runs on Anthropic's server-side search; a hand-rolled * one would need a scrape target or API key, so the pi prompt tells the model * to ask for URLs instead. */ import type { PiTool } from './types.js'; const FETCH_TIMEOUT_MS = 30_000; const MAX_OUTPUT_CHARS = 50_000; /** Raw-byte read budget — HTML needs headroom over the text cap (markup is * stripped), but the body must never be fully buffered: a multi-GB URL would * otherwise spike the single supervisor process (review D-TOOLS-6). */ const MAX_BODY_BYTES = 2 * 1024 * 1024; const MAX_REDIRECTS_NOTE = 'follow'; // fetch follows redirects by default /** Stream-read up to MAX_BODY_BYTES, then cancel the rest of the body. */ async function readBodyCapped(res: Response): Promise<{ text: string; bodyTruncated: boolean }> { if (!res.body) return { text: '', bodyTruncated: false }; const reader = res.body.getReader(); const decoder = new TextDecoder(); let text = ''; let bytes = 0; let bodyTruncated = false; try { while (true) { const { value, done } = await reader.read(); if (done) break; if (value) { bytes += value.byteLength; text += decoder.decode(value, { stream: true }); if (bytes >= MAX_BODY_BYTES) { bodyTruncated = true; try { await reader.cancel(); } catch {} break; } } } } finally { try { reader.releaseLock(); } catch {} } text += decoder.decode(); return { text, bodyTruncated }; } function htmlToText(html: string): string { return html .replace(//gi, ' ') .replace(//gi, ' ') .replace(//gi, ' ') .replace(//g, ' ') .replace(//gi, '\n') .replace(/<\/(p|div|h[1-6]|li|tr|section|article|header|footer)>/gi, '\n') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/[ \t]+/g, ' ') .replace(/\n\s*\n\s*\n+/g, '\n\n') .trim(); } export const webFetchTool: PiTool = { name: 'WebFetch', description: 'Fetch a web page or API endpoint over HTTP(S) and return its readable text content. ' + 'HTML is stripped to text; JSON and plain text are returned as-is (capped).', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Absolute http:// or https:// URL to fetch.' }, }, required: ['url'], }, async run(input, ctx) { const raw = typeof input?.url === 'string' ? input.url.trim() : ''; let url: URL; try { url = new URL(raw); } catch { return { output: `Invalid URL: ${raw || '(empty)'}`, isError: true }; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { return { output: `Only http(s) URLs are supported (got ${url.protocol}).`, isError: true }; } const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); const onOuterAbort = () => ctl.abort(); ctx.signal?.addEventListener('abort', onOuterAbort, { once: true }); try { const res = await fetch(url, { signal: ctl.signal, redirect: MAX_REDIRECTS_NOTE, headers: { 'user-agent': 'Bloby/1.0 (+https://bloby.bot)', accept: 'text/html,application/json,text/plain,*/*' }, }); const declared = Number(res.headers.get('content-length') || 0); if (declared > 50 * 1024 * 1024) { try { await res.body?.cancel(); } catch {} return { output: `Response too large to fetch (${Math.round(declared / 1024 / 1024)} MB). Use Bash (curl) to download it to disk instead.`, isError: true }; } const { text: body, bodyTruncated } = await readBodyCapped(res); const contentType = res.headers.get('content-type') || ''; const text = /text\/html|application\/xhtml/i.test(contentType) ? htmlToText(body) : body.trim(); const capped = text.length > MAX_OUTPUT_CHARS || bodyTruncated ? `${text.slice(0, MAX_OUTPUT_CHARS)}\n\n[Truncated${bodyTruncated ? ` — read stopped at ${MAX_BODY_BYTES / 1024 / 1024} MB` : ` at ${MAX_OUTPUT_CHARS} characters`}]` : text; if (!res.ok) { return { output: `HTTP ${res.status} ${res.statusText} from ${url.host}\n\n${capped.slice(0, 2000)}`, isError: true }; } return { output: capped || '(empty response body)' }; } catch (err: any) { const msg = err?.name === 'AbortError' ? (ctx.signal?.aborted ? 'Fetch aborted (session ended).' : `Fetch timed out after ${FETCH_TIMEOUT_MS / 1000}s.`) : `Fetch failed: ${err?.message || err}`; return { output: msg, isError: true }; } finally { clearTimeout(timer); ctx.signal?.removeEventListener('abort', onOuterAbort); } }, };