From 211f6c69110a3614d9acbc340c8b52d5782fd437 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 18 Aug 2026 20:14:17 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20richer=20web=5Ffetch=20=E2=80=94=20?= =?UTF-8?q?turndown=20markdown,=20inline=20images,=20header-aware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the fold web_fetch tool up to the fidelity of the agentlayer version it was ported from. - Markdown: replace the hand-rolled regex converter with Turndown (atx headings, fenced code; strips script/style/meta/link/noscript/ iframe). Real links, tables, code blocks, and nested lists survive. - Images: route image responses through the existing fold image pipeline (processImage: sniff, resize, re-encode under the inline limit) and return a native image content block, like the read tool. A base64 data URI in tool_result JSON is inert (the provider will not render it), so this required widening the web_fetch contract's success type from Schema.String to ToolResultContent. - Headers: detect HTML and images via content-type, and early-reject on the content-length header. The streaming 5MB cap stays as the real guard for when the header lies or is absent, and now covers the image path too. - User-agent: present as desktop Chrome instead of fold/1.0 so sites that block bot agents still respond. turndown/@types/turndown are pinned in the workspace catalog. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a017bb-e341-783e-b310-01448ba73f0f --- bun.lock | 10 ++ package.json | 2 + packages/fold-agent/package.json | 2 + packages/fold-agent/src/Tools/WebFetchTool.ts | 111 ++++++++++++++---- packages/fold-core/src/Tools/Contracts.ts | 9 +- 5 files changed, 106 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index e6d6cfe..46cb0d5 100644 --- a/bun.lock +++ b/bun.lock @@ -49,12 +49,14 @@ "@humanlayer/fold-opencode": "workspace:*", "@humanlayer/fold-xai": "workspace:*", "@silvia-odwyer/photon-node": "catalog:", + "turndown": "catalog:", "yaml": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@humanlayer/fold-vitest-config": "workspace:*", + "@types/turndown": "catalog:", "effect": "catalog:", "typescript": "catalog:", "vitest": "catalog:", @@ -225,9 +227,11 @@ "@silvia-odwyer/photon-node": "0.3.4", "@types/bun": "1.3.14", "@types/node": "26.1.0", + "@types/turndown": "5.0.6", "@vitest/coverage-v8": "4.1.9", "effect": "4.0.0-rc.109", "solid-js": "1.9.12", + "turndown": "7.2.4", "typescript": "7.0.2", "vitest": "4.1.9", "yaml": "2.9.0", @@ -365,6 +369,8 @@ "@kitlangton/terminal-control-linux-x64-gnu": ["@kitlangton/terminal-control-linux-x64-gnu@0.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "termctrl": "bin/termctrl" } }, "sha512-09tVwkapRjgXddYPJ3L2PMMhV+49meD0KCuDnzh3/FpK9IuW7svbrvn830J8ztudfOThNIT91zxiddBAgA/aCw=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], @@ -557,6 +563,8 @@ "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -863,6 +871,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici": ["undici@8.7.0", "", {}, "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ=="], diff --git a/package.json b/package.json index 73fbb29..b179c7a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@effect/platform-node": "4.0.0-rc.109", "@effect/vitest": "4.0.0-rc.109", "@silvia-odwyer/photon-node": "0.3.4", + "turndown": "7.2.4", + "@types/turndown": "5.0.6", "yaml": "2.9.0", "vitest": "4.1.9", "@vitest/coverage-v8": "4.1.9", diff --git a/packages/fold-agent/package.json b/packages/fold-agent/package.json index f6edb5c..04b5f77 100644 --- a/packages/fold-agent/package.json +++ b/packages/fold-agent/package.json @@ -20,6 +20,7 @@ "@humanlayer/fold-opencode": "workspace:*", "@humanlayer/fold-xai": "workspace:*", "@silvia-odwyer/photon-node": "catalog:", + "turndown": "catalog:", "yaml": "catalog:" }, "peerDependencies": { @@ -30,6 +31,7 @@ "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@humanlayer/fold-vitest-config": "workspace:*", + "@types/turndown": "catalog:", "effect": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/fold-agent/src/Tools/WebFetchTool.ts b/packages/fold-agent/src/Tools/WebFetchTool.ts index ffbb87e..30be01c 100644 --- a/packages/fold-agent/src/Tools/WebFetchTool.ts +++ b/packages/fold-agent/src/Tools/WebFetchTool.ts @@ -1,10 +1,24 @@ -import { defineTool, webFetchToolContract, type FoldTool } from '@humanlayer/fold-core' +import { + defineTool, + textResult, + webFetchToolContract, + type FoldTool, + type ToolResultBlock, +} from '@humanlayer/fold-core' import { Effect, Predicate } from 'effect' +import TurndownService from 'turndown' + +import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' +import { processImage } from './Image/Process' const maxResponseSize = 5 * 1024 * 1024 const defaultTimeoutMs = 30_000 const maxTimeoutMs = 120_000 +/** Desktop Chrome UA: bot user agents are blocked by many sites, so present as a real browser. */ +const browserUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' + const stripHtmlTags = (html: string): string => html .replace(/)<[^<]*)*<\/script>/gi, '') @@ -19,26 +33,42 @@ const stripHtmlTags = (html: string): string => .replace(/\n{3,}/g, '\n\n') .trim() -const htmlToMarkdown = (html: string): string => - stripHtmlTags( - html - .replace(/<\s*br\s*\/?\s*>/gi, '\n') - .replace(/<\s*\/p\s*>/gi, '\n\n') - .replace(/<\s*\/h([1-6])\s*>/gi, '\n\n') - .replace(/<\s*h([1-6])[^>]*>/gi, (_match, level: string) => `\n\n${'#'.repeat(Number(level))} `) - .replace(/<\s*li[^>]*>/gi, '\n- ') - .replace(/<\s*\/li\s*>/gi, ''), - ) - -const isHtml = (body: string): boolean => { +/** One Turndown service per tool value: atx headings, fenced code, and no script/style/meta noise. */ +const makeTurndown = (): TurndownService => { + const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }) + turndown.remove(['script', 'style', 'meta', 'link', 'noscript', 'iframe']) + return turndown +} + +/** HTML by content-type or by a leading document marker (matches the pi/agentlayer heuristic). */ +const isHtml = (contentType: string, body: string): boolean => { + if (contentType.includes('text/html')) return true const trimmed = body.trimStart().toLowerCase() - return trimmed.startsWith(' => +/** + * The image MIME to hand the resize pipeline, or null for non-images. Prefer a magic-byte sniff (robust + * against wrong headers); fall back to a non-SVG `image/*` content-type so mislabeled-but-real images + * still route to `processImage`, which converts unknown formats to PNG. + */ +const imageMimeFor = (bytes: Uint8Array, contentType: string): string | null => { + const sniffed = detectSupportedImageMimeType(bytes.subarray(0, imageSniffBytes)) + if (sniffed !== null) return sniffed + if (contentType.startsWith('image/') && !contentType.includes('svg')) + return contentType.split(';')[0]?.trim() ?? null + return null +} + +/** Read the response body to bytes, enforcing the 5MB cap while streaming so an oversize body never fully buffers. */ +const readBytes = (response: Response): Effect.Effect => Effect.tryPromise({ try: async () => { - if (response.body === null) return await response.text() + if (response.body === null) { + const buffered = new Uint8Array(await response.arrayBuffer()) + if (buffered.byteLength > maxResponseSize) throw new Error('Response too large (exceeds 5MB limit)') + return buffered + } const reader = response.body.getReader() const chunks: Array = [] @@ -63,13 +93,15 @@ const readBody = (response: Response): Effect.Effect ({ message: Predicate.isError(error) ? error.message : String(error) }), }) -export const webFetchTool = (): FoldTool => - defineTool({ +export const webFetchTool = (): FoldTool => { + const turndown = makeTurndown() + + return defineTool({ ...webFetchToolContract, handler: (params) => Effect.gen(function* () { @@ -86,7 +118,7 @@ export const webFetchTool = (): FoldTool => try: () => fetch(params.url, { signal: controller.signal, - headers: { 'user-agent': 'Mozilla/5.0 (compatible; fold/1.0)' }, + headers: { 'user-agent': browserUserAgent }, }), catch: (error) => ({ message: @@ -102,11 +134,42 @@ export const webFetchTool = (): FoldTool => return yield* Effect.fail({ message: `Request failed with status code: ${response.status}` }) } - const body = yield* readBody(response) + // Cheap early reject on the advertised size; the streaming read still enforces the cap when + // the header is missing or lies. + const contentLength = response.headers.get('content-length') + if (contentLength !== null && Number.parseInt(contentLength, 10) > maxResponseSize) { + return yield* Effect.fail({ message: 'Response too large (exceeds 5MB limit)' }) + } + + const contentType = (response.headers.get('content-type') ?? '').toLowerCase() + const bytes = yield* readBytes(response) + + // Images: normalize/resize through the shared pipeline and return a native image content block. + // A base64 data URI in tool_result JSON is not rendered as an image by the provider (D3). + const imageMimeType = imageMimeFor(bytes, contentType) + if (imageMimeType !== null) { + const processed = yield* Effect.promise(() => processImage(bytes, imageMimeType)) + if (!processed.ok) { + return textResult(`Fetched image [${imageMimeType}]\n${processed.message}`) + } + + const note = [ + `Fetched image [${processed.mimeType}] from ${params.url}`, + ...processed.hints, + ].join('\n') + const blocks: Array = [ + { type: 'text', text: note }, + { type: 'image', data: processed.data, mimeType: processed.mimeType }, + ] + return { content: blocks } + } + + const body = new TextDecoder().decode(bytes) const format = params.format ?? 'markdown' - if (format === 'html') return body - if (!isHtml(body)) return body - return format === 'text' ? stripHtmlTags(body) : htmlToMarkdown(body) + if (format === 'html') return textResult(body) + if (!isHtml(contentType, body)) return textResult(body) + return textResult(format === 'text' ? stripHtmlTags(body) : turndown.turndown(body)) }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer)))) }), }) +} diff --git a/packages/fold-core/src/Tools/Contracts.ts b/packages/fold-core/src/Tools/Contracts.ts index 7049c93..f425f2e 100644 --- a/packages/fold-core/src/Tools/Contracts.ts +++ b/packages/fold-core/src/Tools/Contracts.ts @@ -179,12 +179,13 @@ const WebFetchParameters = Schema.Struct({ export const webFetchToolContract = { name: 'web_fetch', description: - 'Fetch content from a URL and return it as markdown, plain text, or raw HTML. Use this for specific ' + - 'official docs pages, articles, and other known sources. Responses over 5MB are rejected.', + 'Fetch content from a URL and return it as markdown, plain text, or raw HTML. Image URLs (jpeg, png, ' + + 'gif, webp, bmp) are returned as inline images. Use this for specific official docs pages, articles, ' + + 'and other known sources. Responses over 5MB are rejected.', parameters: WebFetchParameters, - success: Schema.String, + success: ToolResultContent, failure: ToolFailure, -} satisfies ToolContract +} satisfies ToolContract // --- web_search ------------------------------------------------------------------------------------- From 0b6dca9f019439ba7c618af1ac0381ec0656be4c Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Wed, 19 Aug 2026 09:52:07 -0700 Subject: [PATCH 2/3] refactor: make web_fetch Effect-native and add real HTTP tests Rework the transport off raw Web APIs onto Effect's HTTP client, and prove the tool with a real loopback server instead of shipping it untested. - Transport: use `HttpClient.get` with `FetchHttpClient.layer` instead of global `fetch`. The body is folded off `response.stream` with `Stream.runFoldEffect`, keeping the 5MB cap as a streaming guard with no manual reader loop or `await response.arrayBuffer()`. - Deadline: `Effect.timeoutOrElse` (interruptible) replaces the manual `AbortController` + `setTimeout` the tsgo plugin flagged. - Headers: `Headers.get` + `Option` for content-type; the manual `Number.parseInt` content-length probe is gone (the streaming cap is the real bound, so the probe added only a parse). - Failures stay in the error channel and narrow to the tool's `{ message }` via `catchTag`; the operation carries a `tool.web_fetch` span. Tests (`WebFetchTool.vi.test.ts`) run against a real `node:http` loopback server: turndown markdown (links/headings/lists/fenced code), text and html formats, non-HTML passthrough, a genuine 2100x700 gradient BMP that forces the convert+resize pipeline (asserts a real PNG under 2000px, not a 1x1 placeholder), the streaming 5MB cap with no content-length, non-2xx status, invalid-scheme rejection, and timeout. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a017bb-e341-783e-b310-01448ba73f0f --- packages/fold-agent/src/Tools/WebFetchTool.ts | 184 ++++++++------- .../test/Tools/WebFetchTool.vi.test.ts | 213 ++++++++++++++++++ 2 files changed, 301 insertions(+), 96 deletions(-) create mode 100644 packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts diff --git a/packages/fold-agent/src/Tools/WebFetchTool.ts b/packages/fold-agent/src/Tools/WebFetchTool.ts index 30be01c..0f37abb 100644 --- a/packages/fold-agent/src/Tools/WebFetchTool.ts +++ b/packages/fold-agent/src/Tools/WebFetchTool.ts @@ -5,7 +5,9 @@ import { type FoldTool, type ToolResultBlock, } from '@humanlayer/fold-core' -import { Effect, Predicate } from 'effect' +import { Duration, Effect, Option, Stream } from 'effect' +import { FetchHttpClient, Headers, HttpClient } from 'effect/unstable/http' +import type { HttpClientResponse } from 'effect/unstable/http' import TurndownService from 'turndown' import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' @@ -14,6 +16,7 @@ import { processImage } from './Image/Process' const maxResponseSize = 5 * 1024 * 1024 const defaultTimeoutMs = 30_000 const maxTimeoutMs = 120_000 +const tooLargeMessage = 'Response too large (exceeds 5MB limit)' /** Desktop Chrome UA: bot user agents are blocked by many sites, so present as a real browser. */ const browserUserAgent = @@ -60,46 +63,87 @@ const imageMimeFor = (bytes: Uint8Array, contentType: string): string | null => return null } -/** Read the response body to bytes, enforcing the 5MB cap while streaming so an oversize body never fully buffers. */ -const readBytes = (response: Response): Effect.Effect => - Effect.tryPromise({ - try: async () => { - if (response.body === null) { - const buffered = new Uint8Array(await response.arrayBuffer()) - if (buffered.byteLength > maxResponseSize) throw new Error('Response too large (exceeds 5MB limit)') - return buffered +/** Flatten the collected body chunks into one contiguous buffer. */ +const concatChunks = (chunks: ReadonlyArray, size: number): Uint8Array => { + const out = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +type BodyAccumulator = { readonly size: number; readonly chunks: ReadonlyArray } + +/** + * Fold the response body stream into bytes, failing the moment the running total crosses the 5MB cap so + * an oversize body is never fully buffered. Transport failures mid-body narrow to the tool's message. + */ +const collectCappedBytes = ( + url: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect => + Stream.runFoldEffect( + response.stream, + (): BodyAccumulator => ({ size: 0, chunks: [] }), + (accumulated, chunk): Effect.Effect => + accumulated.size + chunk.length > maxResponseSize + ? Effect.fail({ message: tooLargeMessage }) + : Effect.succeed({ size: accumulated.size + chunk.length, chunks: [...accumulated.chunks, chunk] }), + ).pipe( + Effect.map((accumulated) => concatChunks(accumulated.chunks, accumulated.size)), + Effect.catchTag('HttpClientError', (error) => + Effect.fail({ message: `Failed to read response from ${url}: ${error.reason.message}` }), + ), + ) + +export const webFetchTool = (): FoldTool => { + const turndown = makeTurndown() + + const fetchAndRender = (params: typeof webFetchToolContract.parameters.Type) => + Effect.gen(function* () { + const response = yield* HttpClient.get(params.url, { + headers: { 'user-agent': browserUserAgent }, + }).pipe( + Effect.catchTag('HttpClientError', (error) => + Effect.fail({ message: `Failed to fetch ${params.url}: ${error.reason.message}` }), + ), + ) + + if (response.status < 200 || response.status >= 300) { + return yield* Effect.fail({ message: `Request failed with status code: ${response.status}` }) } - const reader = response.body.getReader() - const chunks: Array = [] - let total = 0 - - while (true) { - const { done, value } = await reader.read() - if (done) break - if (value === undefined) continue - total += value.byteLength - if (total > maxResponseSize) { - await reader.cancel() - throw new Error('Response too large (exceeds 5MB limit)') + const contentType = Headers.get(response.headers, 'content-type').pipe( + Option.map((value) => value.toLowerCase()), + Option.getOrElse(() => ''), + ) + const bytes = yield* collectCappedBytes(params.url, response) + + // Images: normalize/resize through the shared pipeline and return a native image content block. + // A base64 data URI in tool_result JSON is not rendered as an image by the provider (D3). + const imageMimeType = imageMimeFor(bytes, contentType) + if (imageMimeType !== null) { + const processed = yield* Effect.promise(() => processImage(bytes, imageMimeType)) + if (!processed.ok) { + return textResult(`Fetched image [${imageMimeType}]\n${processed.message}`) } - chunks.push(value) - } - const bytes = new Uint8Array(total) - let offset = 0 - for (const chunk of chunks) { - bytes.set(chunk, offset) - offset += chunk.byteLength + const note = [`Fetched image [${processed.mimeType}] from ${params.url}`, ...processed.hints].join('\n') + const blocks: ReadonlyArray = [ + { type: 'text', text: note }, + { type: 'image', data: processed.data, mimeType: processed.mimeType }, + ] + return { content: blocks } } - return bytes - }, - catch: (error) => ({ message: Predicate.isError(error) ? error.message : String(error) }), - }) - -export const webFetchTool = (): FoldTool => { - const turndown = makeTurndown() + const body = new TextDecoder().decode(bytes) + const format = params.format ?? 'markdown' + if (format === 'html') return textResult(body) + if (!isHtml(contentType, body)) return textResult(body) + return textResult(format === 'text' ? stripHtmlTags(body) : turndown.turndown(body)) + }) return defineTool({ ...webFetchToolContract, @@ -110,66 +154,14 @@ export const webFetchTool = (): FoldTool => { } const timeoutMs = Math.min(params.timeout ?? defaultTimeoutMs, maxTimeoutMs) - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - - return yield* Effect.gen(function* () { - const response = yield* Effect.tryPromise({ - try: () => - fetch(params.url, { - signal: controller.signal, - headers: { 'user-agent': browserUserAgent }, - }), - catch: (error) => ({ - message: - Predicate.isError(error) && error.name === 'AbortError' - ? `Request timed out after ${timeoutMs}ms` - : Predicate.isError(error) - ? error.message - : String(error), - }), - }) - - if (!response.ok) { - return yield* Effect.fail({ message: `Request failed with status code: ${response.status}` }) - } - - // Cheap early reject on the advertised size; the streaming read still enforces the cap when - // the header is missing or lies. - const contentLength = response.headers.get('content-length') - if (contentLength !== null && Number.parseInt(contentLength, 10) > maxResponseSize) { - return yield* Effect.fail({ message: 'Response too large (exceeds 5MB limit)' }) - } - - const contentType = (response.headers.get('content-type') ?? '').toLowerCase() - const bytes = yield* readBytes(response) - - // Images: normalize/resize through the shared pipeline and return a native image content block. - // A base64 data URI in tool_result JSON is not rendered as an image by the provider (D3). - const imageMimeType = imageMimeFor(bytes, contentType) - if (imageMimeType !== null) { - const processed = yield* Effect.promise(() => processImage(bytes, imageMimeType)) - if (!processed.ok) { - return textResult(`Fetched image [${imageMimeType}]\n${processed.message}`) - } - - const note = [ - `Fetched image [${processed.mimeType}] from ${params.url}`, - ...processed.hints, - ].join('\n') - const blocks: Array = [ - { type: 'text', text: note }, - { type: 'image', data: processed.data, mimeType: processed.mimeType }, - ] - return { content: blocks } - } - - const body = new TextDecoder().decode(bytes) - const format = params.format ?? 'markdown' - if (format === 'html') return textResult(body) - if (!isHtml(contentType, body)) return textResult(body) - return textResult(format === 'text' ? stripHtmlTags(body) : turndown.turndown(body)) - }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer)))) - }), + + return yield* fetchAndRender(params).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => Effect.fail({ message: `Request timed out after ${timeoutMs}ms` }), + }), + Effect.provide(FetchHttpClient.layer), + ) + }).pipe(Effect.withSpan('tool.web_fetch', { attributes: { 'web_fetch.url': params.url } })), }) } diff --git a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts new file mode 100644 index 0000000..2455f6a --- /dev/null +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -0,0 +1,213 @@ +/** + * WebFetchTool exercised against a real loopback HTTP server (the real transport seam, per the testing + * reference). Images use a genuine multi-kilobyte gradient bitmap that forces the convert+resize path, + * not a 1x1 placeholder, so the assertions prove the image pipeline actually ran. + */ +import { createServer, type Server } from 'node:http' + +import { it } from '@effect/vitest' +import { ToolResultContent } from '@humanlayer/fold-core' +import { Effect, Schema } from 'effect' +import { afterAll, beforeAll, expect } from 'vitest' + +import { webFetchTool } from '../../src/index' +import { handlerOf, messageOf, runHandler } from '../TestHelpers' + +const richHtml = [ + '', + 'Doc', + '

Title

', + '

Intro with a link inside.

', + '
  • alpha
  • beta
', + '
const x = 1
', + '', +].join('') + +/** A real 24-bit BMP with a per-pixel gradient (not a placeholder): large enough to force a resize. */ +const makeGradientBmp = (width: number, height: number): Uint8Array => { + const rowStride = Math.ceil((width * 3) / 4) * 4 + const pixelBytes = rowStride * height + const fileSize = 54 + pixelBytes + const bytes = new Uint8Array(fileSize) + const view = new DataView(bytes.buffer) + bytes[0] = 0x42 // B + bytes[1] = 0x4d // M + view.setUint32(2, fileSize, true) + view.setUint32(10, 54, true) // pixel data offset + view.setUint32(14, 40, true) // DIB header size + view.setInt32(18, width, true) + view.setInt32(22, height, true) + view.setUint16(26, 1, true) // planes + view.setUint16(28, 24, true) // bits per pixel + view.setUint32(34, pixelBytes, true) + for (let y = 0; y < height; y++) { + let offset = 54 + y * rowStride + for (let x = 0; x < width; x++) { + bytes[offset++] = x % 256 // blue + bytes[offset++] = y % 256 // green + bytes[offset++] = (x + y) % 256 // red + } + } + return bytes +} + +/** Big-endian IHDR width of a PNG (bytes 16-19). */ +const pngWidth = (bytes: Uint8Array): number => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(16, false) + +const isToolResultContent = Schema.is(ToolResultContent) + +const contentOf = (result: unknown): ToolResultContent['content'] => { + if (!isToolResultContent(result)) throw new Error('expected a content-block tool result') + return result.content +} + +const firstText = (result: unknown): string => { + const block = contentOf(result)[0] + if (block?.type !== 'text') throw new Error('expected a text block') + return block.text +} + +const fetchResult = ( + url: string, + options?: { readonly format?: 'markdown' | 'text' | 'html'; readonly timeout?: number }, +) => runHandler(handlerOf(webFetchTool())({ url, ...options })) + +let server: Server +let baseUrl = '' + +beforeAll(async () => { + server = createServer((request, response) => { + const path = request.url ?? '/' + if (path === '/page.html') { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(richHtml) + return + } + if (path === '/plain.txt') { + response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('plain body text') + return + } + if (path === '/image.bmp') { + response.writeHead(200, { 'content-type': 'image/bmp' }) + response.end(Buffer.from(makeGradientBmp(2100, 700))) + return + } + if (path === '/huge') { + // No content-length: the streaming cap is the only guard. 6MB in 1MB chunks trips it at 5MB. + response.writeHead(200, { 'content-type': 'application/octet-stream' }) + response.on('error', () => {}) + for (let index = 0; index < 6; index++) response.write(Buffer.alloc(1024 * 1024, index)) + response.end() + return + } + if (path === '/slow') { + // Never respond; the client's timeout must fire. + return + } + response.writeHead(404) + response.end('not found') + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + const port = typeof address === 'object' && address !== null ? address.port : 0 + baseUrl = `http://127.0.0.1:${port}` +}) + +afterAll(async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) +}) + +it.live('renders HTML as markdown through turndown (links, headings, lists, fenced code)', () => + Effect.gen(function* () { + const markdown = firstText(yield* fetchResult(`${baseUrl}/page.html`)) + + expect(markdown).toContain('# Title') + expect(markdown).toContain('[link](https://example.com/docs)') + expect(markdown).toMatch(/[-*]\s+alpha/) + expect(markdown).toContain('```') + expect(markdown).toContain('const x = 1') + }), +) + +it.live('strips tags for the text format', () => + Effect.gen(function* () { + const text = firstText(yield* fetchResult(`${baseUrl}/page.html`, { format: 'text' })) + + expect(text).toContain('Title') + expect(text).toContain('link') + expect(text).not.toContain('

') + expect(text).not.toContain('# Title') + }), +) + +it.live('returns raw HTML for the html format', () => + Effect.gen(function* () { + const raw = firstText(yield* fetchResult(`${baseUrl}/page.html`, { format: 'html' })) + + expect(raw).toContain('

Title

') + }), +) + +it.live('returns non-HTML bodies unchanged', () => + Effect.gen(function* () { + expect(firstText(yield* fetchResult(`${baseUrl}/plain.txt`))).toBe('plain body text') + }), +) + +it.live('returns a fetched image as a resized PNG content block', () => + Effect.gen(function* () { + const result = yield* fetchResult(`${baseUrl}/image.bmp`) + const blocks = contentOf(result) + + expect(firstText(result)).toContain('Fetched image') + expect(firstText(result)).toContain('converted from image/bmp') + // The 2100px-wide source must be resized under the 2000px limit, proving the pipeline ran. + expect(firstText(result)).toContain('Multiply coordinates') + + const image = blocks[1] + if (image?.type !== 'image') throw new Error('expected an image block') + expect(image.mimeType).toBe('image/png') + + const decoded = new Uint8Array(Buffer.from(image.data, 'base64')) + expect(Array.from(decoded.subarray(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]) // PNG signature + const width = pngWidth(decoded) + expect(width).toBeGreaterThan(1) + expect(width).toBeLessThanOrEqual(2000) + }), +) + +it.live('rejects a response that exceeds the 5MB cap while streaming', () => + Effect.gen(function* () { + const failure = yield* fetchResult(`${baseUrl}/huge`).pipe(Effect.flip) + + expect(messageOf(failure)).toBe('Response too large (exceeds 5MB limit)') + }), +) + +it.live('surfaces a non-2xx status', () => + Effect.gen(function* () { + const failure = yield* fetchResult(`${baseUrl}/notfound`).pipe(Effect.flip) + + expect(messageOf(failure)).toBe('Request failed with status code: 404') + }), +) + +it.live('rejects non-http(s) URLs before making a request', () => + Effect.gen(function* () { + const failure = yield* fetchResult('ftp://example.com/data').pipe(Effect.flip) + + expect(messageOf(failure)).toBe('URL must start with http:// or https://') + }), +) + +it.live('times out a response that never arrives', () => + Effect.gen(function* () { + const failure = yield* fetchResult(`${baseUrl}/slow`, { timeout: 300 }).pipe(Effect.flip) + + expect(messageOf(failure)).toContain('timed out') + }), +) From 155e7e775b9cb35a8171759b2a2187ed4956c375 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Wed, 19 Aug 2026 10:25:02 -0700 Subject: [PATCH 3/3] refactor: factor web_fetch into adapter/render seams; real PNG fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: parse Content-Length with Schema instead of dropping it, and test the image path with a real photograph instead of a synthetic bitmap. - Structure: split the handler into `fetchDocument` (request adapter: execute, classify status, size-gate, read) and `renderDocument` (bytes -> tool result), with small named header parsers. Failures build through one `failWith` helper into the contract's `{ message }`. - Content-Length: restored as an up-front reject, parsed with `Schema.decodeOption(Schema.NumberFromString)` off the raw header (parse, don't validate) — no `Number.parseInt`. The streaming cap remains the guard when the header is absent or lies. - Test fixture: `fixtures/hopper.png` (the standard Pillow 128x128 test photo) replaces the hand-built gradient BMP. The image test asserts a byte-for-byte round-trip of the real PNG plus its 128x128 IHDR dimensions, and a new test covers the Schema-parsed Content-Length precheck (advertises 6MB, sends 16 bytes — only the header gate can reject it). fold-agent 211/211, fold-core 292/292; typecheck/lint/format clean. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a017bb-e341-783e-b310-01448ba73f0f --- packages/fold-agent/src/Tools/WebFetchTool.ts | 228 +++++++++++------- .../test/Tools/WebFetchTool.vi.test.ts | 88 ++++--- packages/fold-agent/test/fixtures/README.md | 7 + packages/fold-agent/test/fixtures/hopper.png | Bin 0 -> 30605 bytes 4 files changed, 185 insertions(+), 138 deletions(-) create mode 100644 packages/fold-agent/test/fixtures/README.md create mode 100644 packages/fold-agent/test/fixtures/hopper.png diff --git a/packages/fold-agent/src/Tools/WebFetchTool.ts b/packages/fold-agent/src/Tools/WebFetchTool.ts index 0f37abb..d08002b 100644 --- a/packages/fold-agent/src/Tools/WebFetchTool.ts +++ b/packages/fold-agent/src/Tools/WebFetchTool.ts @@ -4,8 +4,9 @@ import { webFetchToolContract, type FoldTool, type ToolResultBlock, + type ToolResultContent, } from '@humanlayer/fold-core' -import { Duration, Effect, Option, Stream } from 'effect' +import { Duration, Effect, Option, Schema, Stream } from 'effect' import { FetchHttpClient, Headers, HttpClient } from 'effect/unstable/http' import type { HttpClientResponse } from 'effect/unstable/http' import TurndownService from 'turndown' @@ -22,6 +23,99 @@ const tooLargeMessage = 'Response too large (exceeds 5MB limit)' const browserUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' +type WebFetchParameters = typeof webFetchToolContract.parameters.Type + +/** A tool result is one message value: reuse the whole `{ message }` shape the contract already advertises. */ +type WebFetchFailure = { readonly message: string } + +const failWith = (message: string): Effect.Effect => Effect.fail({ message }) + +// --- header parsing (parse, don't validate: the Content-Length header is untrusted text) --------------- + +/** The advertised body size, decoded from the raw header; `None` when absent or unparseable. */ +const declaredBodySize = (headers: Headers.Headers): Option.Option => + Headers.get(headers, 'content-length').pipe(Option.flatMap(Schema.decodeOption(Schema.NumberFromString))) + +/** The lowercased content-type, or an empty string when the header is absent. */ +const contentTypeOf = (headers: Headers.Headers): string => + Headers.get(headers, 'content-type').pipe( + Option.map((value) => value.toLowerCase()), + Option.getOrElse(() => ''), + ) + +// --- body reading ------------------------------------------------------------------------------------- + +type BodyAccumulator = { readonly size: number; readonly chunks: ReadonlyArray } + +/** Flatten collected chunks into one contiguous buffer. */ +const concatChunks = ({ size, chunks }: BodyAccumulator): Uint8Array => { + const out = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +/** + * Fold the body stream into bytes, failing the moment the running total crosses the 5MB cap so an + * oversize body is never fully buffered. A mid-body transport failure narrows to the tool's message. + */ +const collectCappedBytes = ( + url: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect => + Stream.runFoldEffect( + response.stream, + (): BodyAccumulator => ({ size: 0, chunks: [] }), + (accumulated, chunk): Effect.Effect => + accumulated.size + chunk.length > maxResponseSize + ? failWith(tooLargeMessage) + : Effect.succeed({ size: accumulated.size + chunk.length, chunks: [...accumulated.chunks, chunk] }), + ).pipe( + Effect.map(concatChunks), + Effect.catchTag('HttpClientError', (error) => + failWith(`Failed to read response from ${url}: ${error.reason.message}`), + ), + ) + +// --- request adapter ---------------------------------------------------------------------------------- + +type FetchedDocument = { readonly contentType: string; readonly bytes: Uint8Array } + +/** + * Execute the request through Effect's HTTP client, classify status, reject an over-cap body up front by + * its declared length, then read the body under the streaming cap. Transport, status, size, and timeout + * failures all surface as the tool's `{ message }`. Requires an `HttpClient`; the caller provides fetch. + */ +const fetchDocument = ( + url: string, + timeoutMs: number, +): Effect.Effect => + Effect.gen(function* () { + const response = yield* HttpClient.get(url, { headers: { 'user-agent': browserUserAgent } }).pipe( + Effect.catchTag('HttpClientError', (error) => failWith(`Failed to fetch ${url}: ${error.reason.message}`)), + ) + + if (response.status < 200 || response.status >= 300) { + return yield* failWith(`Request failed with status code: ${response.status}`) + } + if (Option.exists(declaredBodySize(response.headers), (size) => size > maxResponseSize)) { + return yield* failWith(tooLargeMessage) + } + + const bytes = yield* collectCappedBytes(url, response) + return { contentType: contentTypeOf(response.headers), bytes } + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => failWith(`Request timed out after ${timeoutMs}ms`), + }), + ) + +// --- rendering ---------------------------------------------------------------------------------------- + const stripHtmlTags = (html: string): string => html .replace(/)<[^<]*)*<\/script>/gi, '') @@ -63,105 +157,57 @@ const imageMimeFor = (bytes: Uint8Array, contentType: string): string | null => return null } -/** Flatten the collected body chunks into one contiguous buffer. */ -const concatChunks = (chunks: ReadonlyArray, size: number): Uint8Array => { - const out = new Uint8Array(size) - let offset = 0 - for (const chunk of chunks) { - out.set(chunk, offset) - offset += chunk.length - } - return out -} - -type BodyAccumulator = { readonly size: number; readonly chunks: ReadonlyArray } - /** - * Fold the response body stream into bytes, failing the moment the running total crosses the 5MB cap so - * an oversize body is never fully buffered. Transport failures mid-body narrow to the tool's message. + * Turn fetched bytes into a tool result. Images go through the shared resize pipeline and return a native + * image content block (a base64 data URI in tool_result JSON is not rendered as an image by the provider, + * D3); everything else renders as markdown, plain text, or raw HTML per the requested format. */ -const collectCappedBytes = ( +const renderDocument = ( url: string, - response: HttpClientResponse.HttpClientResponse, -): Effect.Effect => - Stream.runFoldEffect( - response.stream, - (): BodyAccumulator => ({ size: 0, chunks: [] }), - (accumulated, chunk): Effect.Effect => - accumulated.size + chunk.length > maxResponseSize - ? Effect.fail({ message: tooLargeMessage }) - : Effect.succeed({ size: accumulated.size + chunk.length, chunks: [...accumulated.chunks, chunk] }), - ).pipe( - Effect.map((accumulated) => concatChunks(accumulated.chunks, accumulated.size)), - Effect.catchTag('HttpClientError', (error) => - Effect.fail({ message: `Failed to read response from ${url}: ${error.reason.message}` }), - ), - ) + document: FetchedDocument, + format: 'markdown' | 'text' | 'html', + turndown: TurndownService, +): Effect.Effect => + Effect.gen(function* () { + const imageMimeType = imageMimeFor(document.bytes, document.contentType) + if (imageMimeType !== null) { + const processed = yield* Effect.promise(() => processImage(document.bytes, imageMimeType)) + if (!processed.ok) { + return textResult(`Fetched image [${imageMimeType}]\n${processed.message}`) + } + + const note = [`Fetched image [${processed.mimeType}] from ${url}`, ...processed.hints].join('\n') + const blocks: ReadonlyArray = [ + { type: 'text', text: note }, + { type: 'image', data: processed.data, mimeType: processed.mimeType }, + ] + return { content: blocks } + } + + const body = new TextDecoder().decode(document.bytes) + if (format === 'html') return textResult(body) + if (!isHtml(document.contentType, body)) return textResult(body) + return textResult(format === 'text' ? stripHtmlTags(body) : turndown.turndown(body)) + }) + +// --- tool --------------------------------------------------------------------------------------------- export const webFetchTool = (): FoldTool => { const turndown = makeTurndown() - const fetchAndRender = (params: typeof webFetchToolContract.parameters.Type) => + const runWebFetch = (params: WebFetchParameters): Effect.Effect => Effect.gen(function* () { - const response = yield* HttpClient.get(params.url, { - headers: { 'user-agent': browserUserAgent }, - }).pipe( - Effect.catchTag('HttpClientError', (error) => - Effect.fail({ message: `Failed to fetch ${params.url}: ${error.reason.message}` }), - ), - ) - - if (response.status < 200 || response.status >= 300) { - return yield* Effect.fail({ message: `Request failed with status code: ${response.status}` }) + if (!params.url.startsWith('http://') && !params.url.startsWith('https://')) { + return yield* failWith('URL must start with http:// or https://') } - const contentType = Headers.get(response.headers, 'content-type').pipe( - Option.map((value) => value.toLowerCase()), - Option.getOrElse(() => ''), - ) - const bytes = yield* collectCappedBytes(params.url, response) - - // Images: normalize/resize through the shared pipeline and return a native image content block. - // A base64 data URI in tool_result JSON is not rendered as an image by the provider (D3). - const imageMimeType = imageMimeFor(bytes, contentType) - if (imageMimeType !== null) { - const processed = yield* Effect.promise(() => processImage(bytes, imageMimeType)) - if (!processed.ok) { - return textResult(`Fetched image [${imageMimeType}]\n${processed.message}`) - } - - const note = [`Fetched image [${processed.mimeType}] from ${params.url}`, ...processed.hints].join('\n') - const blocks: ReadonlyArray = [ - { type: 'text', text: note }, - { type: 'image', data: processed.data, mimeType: processed.mimeType }, - ] - return { content: blocks } - } + const timeoutMs = Math.min(params.timeout ?? defaultTimeoutMs, maxTimeoutMs) + const document = yield* fetchDocument(params.url, timeoutMs) + return yield* renderDocument(params.url, document, params.format ?? 'markdown', turndown) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.withSpan('tool.web_fetch', { attributes: { url: params.url } }), + ) - const body = new TextDecoder().decode(bytes) - const format = params.format ?? 'markdown' - if (format === 'html') return textResult(body) - if (!isHtml(contentType, body)) return textResult(body) - return textResult(format === 'text' ? stripHtmlTags(body) : turndown.turndown(body)) - }) - - return defineTool({ - ...webFetchToolContract, - handler: (params) => - Effect.gen(function* () { - if (!params.url.startsWith('http://') && !params.url.startsWith('https://')) { - return yield* Effect.fail({ message: 'URL must start with http:// or https://' }) - } - - const timeoutMs = Math.min(params.timeout ?? defaultTimeoutMs, maxTimeoutMs) - - return yield* fetchAndRender(params).pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(timeoutMs), - orElse: () => Effect.fail({ message: `Request timed out after ${timeoutMs}ms` }), - }), - Effect.provide(FetchHttpClient.layer), - ) - }).pipe(Effect.withSpan('tool.web_fetch', { attributes: { 'web_fetch.url': params.url } })), - }) + return defineTool({ ...webFetchToolContract, handler: runWebFetch }) } diff --git a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts index 2455f6a..bc24413 100644 --- a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -1,8 +1,10 @@ /** * WebFetchTool exercised against a real loopback HTTP server (the real transport seam, per the testing - * reference). Images use a genuine multi-kilobyte gradient bitmap that forces the convert+resize path, - * not a 1x1 placeholder, so the assertions prove the image pipeline actually ran. + * reference). The image case serves a genuine 128x128 photograph (`fixtures/hopper.png`, the standard + * Pillow test image), so the assertions prove the tool fetches and returns a real image, not a + * hand-built placeholder. */ +import { readFileSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { it } from '@effect/vitest' @@ -13,6 +15,9 @@ import { afterAll, beforeAll, expect } from 'vitest' import { webFetchTool } from '../../src/index' import { handlerOf, messageOf, runHandler } from '../TestHelpers' +const hopperPng = readFileSync(new URL('../fixtures/hopper.png', import.meta.url)) +const hopperBase64 = hopperPng.toString('base64') + const richHtml = [ '', 'Doc', @@ -23,38 +28,12 @@ const richHtml = [ '', ].join('') -/** A real 24-bit BMP with a per-pixel gradient (not a placeholder): large enough to force a resize. */ -const makeGradientBmp = (width: number, height: number): Uint8Array => { - const rowStride = Math.ceil((width * 3) / 4) * 4 - const pixelBytes = rowStride * height - const fileSize = 54 + pixelBytes - const bytes = new Uint8Array(fileSize) - const view = new DataView(bytes.buffer) - bytes[0] = 0x42 // B - bytes[1] = 0x4d // M - view.setUint32(2, fileSize, true) - view.setUint32(10, 54, true) // pixel data offset - view.setUint32(14, 40, true) // DIB header size - view.setInt32(18, width, true) - view.setInt32(22, height, true) - view.setUint16(26, 1, true) // planes - view.setUint16(28, 24, true) // bits per pixel - view.setUint32(34, pixelBytes, true) - for (let y = 0; y < height; y++) { - let offset = 54 + y * rowStride - for (let x = 0; x < width; x++) { - bytes[offset++] = x % 256 // blue - bytes[offset++] = y % 256 // green - bytes[offset++] = (x + y) % 256 // red - } - } - return bytes +/** Width/height read from a PNG IHDR (bytes 16-23, big-endian). */ +const pngDimensions = (bytes: Uint8Array): { readonly width: number; readonly height: number } => { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + return { width: view.getUint32(16, false), height: view.getUint32(20, false) } } -/** Big-endian IHDR width of a PNG (bytes 16-19). */ -const pngWidth = (bytes: Uint8Array): number => - new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(16, false) - const isToolResultContent = Schema.is(ToolResultContent) const contentOf = (result: unknown): ToolResultContent['content'] => { @@ -89,12 +68,12 @@ beforeAll(async () => { response.end('plain body text') return } - if (path === '/image.bmp') { - response.writeHead(200, { 'content-type': 'image/bmp' }) - response.end(Buffer.from(makeGradientBmp(2100, 700))) + if (path === '/photo.png') { + response.writeHead(200, { 'content-type': 'image/png' }) + response.end(hopperPng) return } - if (path === '/huge') { + if (path === '/streamed-huge') { // No content-length: the streaming cap is the only guard. 6MB in 1MB chunks trips it at 5MB. response.writeHead(200, { 'content-type': 'application/octet-stream' }) response.on('error', () => {}) @@ -102,6 +81,16 @@ beforeAll(async () => { response.end() return } + if (path === '/declared-huge') { + // Advertises 6MB but sends almost nothing: only the content-length precheck can reject this. + response.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-length': String(6 * 1024 * 1024), + }) + response.on('error', () => {}) + response.end(Buffer.alloc(16)) + return + } if (path === '/slow') { // Never respond; the client's timeout must fire. return @@ -158,31 +147,36 @@ it.live('returns non-HTML bodies unchanged', () => }), ) -it.live('returns a fetched image as a resized PNG content block', () => +it.live('returns a fetched PNG photo as an image content block', () => Effect.gen(function* () { - const result = yield* fetchResult(`${baseUrl}/image.bmp`) + const result = yield* fetchResult(`${baseUrl}/photo.png`) const blocks = contentOf(result) - expect(firstText(result)).toContain('Fetched image') - expect(firstText(result)).toContain('converted from image/bmp') - // The 2100px-wide source must be resized under the 2000px limit, proving the pipeline ran. - expect(firstText(result)).toContain('Multiply coordinates') + expect(firstText(result)).toContain('Fetched image [image/png]') const image = blocks[1] if (image?.type !== 'image') throw new Error('expected an image block') expect(image.mimeType).toBe('image/png') + // A real 128x128 photo is within the resize limits, so it round-trips byte-for-byte. + expect(image.data).toBe(hopperBase64) const decoded = new Uint8Array(Buffer.from(image.data, 'base64')) expect(Array.from(decoded.subarray(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]) // PNG signature - const width = pngWidth(decoded) - expect(width).toBeGreaterThan(1) - expect(width).toBeLessThanOrEqual(2000) + expect(pngDimensions(decoded)).toEqual({ width: 128, height: 128 }) + }), +) + +it.live('rejects up front when the declared content-length exceeds the cap', () => + Effect.gen(function* () { + const failure = yield* fetchResult(`${baseUrl}/declared-huge`).pipe(Effect.flip) + + expect(messageOf(failure)).toBe('Response too large (exceeds 5MB limit)') }), ) -it.live('rejects a response that exceeds the 5MB cap while streaming', () => +it.live('rejects while streaming when an unmeasured body exceeds the cap', () => Effect.gen(function* () { - const failure = yield* fetchResult(`${baseUrl}/huge`).pipe(Effect.flip) + const failure = yield* fetchResult(`${baseUrl}/streamed-huge`).pipe(Effect.flip) expect(messageOf(failure)).toBe('Response too large (exceeds 5MB limit)') }), diff --git a/packages/fold-agent/test/fixtures/README.md b/packages/fold-agent/test/fixtures/README.md new file mode 100644 index 0000000..f5867d7 --- /dev/null +++ b/packages/fold-agent/test/fixtures/README.md @@ -0,0 +1,7 @@ +# Test fixtures + +- `hopper.png` — the standard "Hopper" photograph used as a test image by the + Pillow project (`python-pillow/Pillow`, `Tests/images/hopper.png`), a real + 128×128 RGB photo. Vendored here as a genuine real-world image for the + web_fetch image path (not a synthetic placeholder). Pillow is distributed + under the permissive HPND license. diff --git a/packages/fold-agent/test/fixtures/hopper.png b/packages/fold-agent/test/fixtures/hopper.png new file mode 100644 index 0000000000000000000000000000000000000000..60ac671a26f801acf00e29f9f42561bd4a2a55e2 GIT binary patch literal 30605 zcmV)>K!d-DP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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 literal 0 HcmV?d00001