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..d08002b 100644 --- a/packages/fold-agent/src/Tools/WebFetchTool.ts +++ b/packages/fold-agent/src/Tools/WebFetchTool.ts @@ -1,9 +1,120 @@ -import { defineTool, webFetchToolContract, type FoldTool } from '@humanlayer/fold-core' -import { Effect, Predicate } from 'effect' +import { + defineTool, + textResult, + webFetchToolContract, + type FoldTool, + type ToolResultBlock, + type ToolResultContent, +} from '@humanlayer/fold-core' +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' + +import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' +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 = + '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 @@ -19,94 +130,84 @@ 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, ''), - ) +/** 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 +} -const isHtml = (body: string): boolean => { +/** 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(' => - Effect.tryPromise({ - try: async () => { - if (response.body === null) return await response.text() - - 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)') - } - chunks.push(value) - } +/** + * 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 +} - const bytes = new Uint8Array(total) - let offset = 0 - for (const chunk of chunks) { - bytes.set(chunk, offset) - offset += chunk.byteLength +/** + * 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 renderDocument = ( + url: string, + 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}`) } - return new TextDecoder().decode(bytes) - }, - catch: (error) => ({ message: Predicate.isError(error) ? error.message : String(error) }), - }) + 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 } + } -export const webFetchTool = (): FoldTool => - 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) - 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': 'Mozilla/5.0 (compatible; fold/1.0)' }, - }), - 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}` }) - } - - const body = yield* readBody(response) - const format = params.format ?? 'markdown' - if (format === 'html') return body - if (!isHtml(body)) return body - return format === 'text' ? stripHtmlTags(body) : htmlToMarkdown(body) - }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer)))) - }), + 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 runWebFetch = (params: WebFetchParameters): Effect.Effect => + Effect.gen(function* () { + if (!params.url.startsWith('http://') && !params.url.startsWith('https://')) { + return yield* failWith('URL must start with http:// or https://') + } + + 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 } }), + ) + + 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 new file mode 100644 index 0000000..bc24413 --- /dev/null +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -0,0 +1,207 @@ +/** + * WebFetchTool exercised against a real loopback HTTP server (the real transport seam, per the testing + * 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' +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 hopperPng = readFileSync(new URL('../fixtures/hopper.png', import.meta.url)) +const hopperBase64 = hopperPng.toString('base64') + +const richHtml = [ + '', + 'Doc', + '

Title

', + '

Intro with a link inside.

', + '
  • alpha
  • beta
', + '
const x = 1
', + '', +].join('') + +/** 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) } +} + +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 === '/photo.png') { + response.writeHead(200, { 'content-type': 'image/png' }) + response.end(hopperPng) + return + } + 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', () => {}) + for (let index = 0; index < 6; index++) response.write(Buffer.alloc(1024 * 1024, index)) + 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 + } + 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 PNG photo as an image content block', () => + Effect.gen(function* () { + const result = yield* fetchResult(`${baseUrl}/photo.png`) + const blocks = contentOf(result) + + 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 + 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 while streaming when an unmeasured body exceeds the cap', () => + Effect.gen(function* () { + const failure = yield* fetchResult(`${baseUrl}/streamed-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') + }), +) 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 0000000..60ac671 Binary files /dev/null and b/packages/fold-agent/test/fixtures/hopper.png differ 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 -------------------------------------------------------------------------------------