UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

242 lines (241 loc) • 15.6 kB
import { delimitUntrusted } from "../../core/untrusted-text.js"; import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js"; import { PDF_FALLBACK_RENDER_PAGES, PDF_INLINE_PAGE_THRESHOLD, PDF_MAX_EXTRACT_SIZE, PDF_MAX_PAGES_PER_READ, PDF_TARGET_RAW_SIZE, extractPdfPagesAsImages, extractPdfTextLayer, getPdfPageCount, parsePdfPageRange, pdfMagicMatches, } from "./pdf.js"; import { resolveAutoDownsampler, enoentMessage, MAX_READ_BYTES } from "./fs-shared.js"; import { planReminderDisclosure, renderReminderDisclosureTrailer } from "../../core/reminder-disclosure.js"; function pdfDisclosureTrailer(body, d) { if (d === undefined) return ""; const plan = planReminderDisclosure({ segments: [body], mark: d.mark, outlet: "pdf", defuseExactMark: false, throttle: { key: d.key, windows: d.windows } }); d.effects.push(plan.effects); const trailer = renderReminderDisclosureTrailer(plan.verdict, d.mark); return trailer !== undefined ? `\n${trailer}` : ""; } export { pdfModelCapabilitiesOf } from "./pdf.js"; async function pdfPagesToImageBlocks(pages, path, downsamplerOpt) { const downsampler = downsamplerOpt === false ? undefined : (downsamplerOpt ?? (await resolveAutoDownsampler())); const blocks = []; for (const p of pages) { let b64; let mime = "image/jpeg"; if (downsampler) { const out = await downsampler(Buffer.from(p.jpeg), "image/jpeg").catch(() => undefined); if (out) { b64 = out.base64; mime = out.mimeType; } } b64 ??= Buffer.from(p.jpeg).toString("base64"); if (b64.length > MCP_IMAGE_MAX_BASE64) { return `Error (Read): rendered page ${p.pageNumber} of "${path}" is too large to send (base64 ${b64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit); narrow the range or extract text via Bash (e.g. \`pdftotext\`).`; } blocks.push({ type: "image", data: b64, mimeType: mime }); } return blocks; } function boundPdfExtractedText(text) { if (Buffer.byteLength(text, "utf8") <= MAX_READ_BYTES) return { body: text, truncated: false }; return { body: text.slice(0, MAX_READ_BYTES), truncated: true }; } export async function readPdfFile(env, path, key, pages, signal, downsamplerOpt, cwd, preRead, caps, readDeny, disclosure) { const cap = caps ?? { document: true, vision: true }; const meta = await env.fileInfo(key, signal); if (!meta.ok) { const ex = await env.exists(key, signal); if (ex.ok && !ex.value) return `Error (Read): ${await enoentMessage(env, key, cwd, signal, readDeny)}`; return `Error (Read): cannot stat PDF "${path}" to verify its size before reading: ${meta.error.message}`; } if (meta.value.kind === "directory") { return `Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`; } const size = preRead ? preRead.byteLength : meta.value.size; if (size > PDF_MAX_EXTRACT_SIZE) { return `Error (Read): PDF "${path}" exceeds the maximum size for PDF reading (${size} bytes > ${PDF_MAX_EXTRACT_SIZE}-byte / 100 MB cap).`; } let bytes = preRead; if (bytes === undefined && size <= PDF_TARGET_RAW_SIZE) { const bin = await env.readBinaryFile(key, signal); if (!bin.ok) return `Error (Read): cannot read PDF "${path}": ${bin.error.message}`; bytes = bin.value; } if (bytes !== undefined && !pdfMagicMatches(bytes)) { return `Error (Read): "${path}" has a .pdf extension but its content is not a valid PDF (no %PDF- header; empty, truncated, or mis-named).`; } if (pages !== undefined) { const range = parsePdfPageRange(pages); if (range === undefined) { return `Error (Read): Invalid pages parameter: "${pages}". Use formats like "1-5", "3", or "10-20". Pages are 1-indexed.`; } if (range.last !== Infinity && range.last - range.first + 1 > PDF_MAX_PAGES_PER_READ) { return `Error (Read): Page range "${pages}" exceeds maximum of ${PDF_MAX_PAGES_PER_READ} pages per request. Please use a smaller range.`; } const pageCount = await getPdfPageCount(env, key, bytes, signal); if (pageCount !== undefined && range.first > pageCount) { return `Error (Read): page range "${pages}" starts past the end of the PDF (${pageCount} page${pageCount === 1 ? "" : "s"}).`; } let last = range.last === Infinity ? range.first + PDF_MAX_PAGES_PER_READ - 1 : range.last; if (pageCount !== undefined) last = Math.min(last, pageCount); if (!cap.document && !cap.vision) { const t = await extractPdfTextLayer(env, key, { first: range.first, last }, signal); if (t.ok) { const { body, truncated } = boundPdfExtractedText(t.text); const timeoutNote = t.timedOutAfterSec !== undefined ? `; PARTIAL — pdftotext timed out after ${t.timedOutAfterSec}s, so the text below covers only what was extracted before the timeout; use a narrower pages range for the rest` : ""; return { content: [ { type: "text", text: `[PDF: ${path} — pages ${range.first}-${last}${pageCount !== undefined ? ` of ${pageCount}` : ""} extracted as text via pdftotext (the serving model does not support native PDF input)${timeoutNote}]` + `\n${delimitUntrusted("PDF text layer", body)}${truncated ? `\n[extracted text truncated at ${MAX_READ_BYTES} bytes; narrow the pages range]` : ""}${pdfDisclosureTrailer(body, disclosure)}`, }, ], details: { type: "document", file: { type: "application/pdf", originalSize: size, ...(pageCount !== undefined ? { pageCount } : {}), pages: `${range.first}-${last}`, ...(t.timedOutAfterSec !== undefined ? { timedOut: true } : {}) }, fallback: { level: 1, reason: "model lacks document input; text layer extracted via pdftotext" }, }, }; } const why = t.toolMissing ? "the poppler utility `pdftotext` is not available in this environment" : `pdftotext failed: ${t.error}`; return { content: [ { type: "text", text: `[PDF pages ${range.first}-${last} of ${path} omitted: the serving model supports neither native PDF (document) input nor image input, and engine-side text extraction did not run (${why}). ` + `Install poppler-utils, or extract the text via Bash (e.g. \`pdftotext -layout -f ${range.first} -l ${last} "${path}" -\`).]`, }, ], details: { type: "document", file: { type: "application/pdf", originalSize: size, ...(pageCount !== undefined ? { pageCount } : {}), pages: `${range.first}-${last}` }, fallback: { level: 3, reason: why }, }, }; } const extract = await extractPdfPagesAsImages(env, key, range.first, last, signal); if (!extract.ok) { if (extract.toolMissing) { return (`Error (Read): reading a PDF page range requires the poppler utility \`pdftoppm\`, which is not available in this environment. ` + `Read the whole PDF without \`pages\` (works up to ${PDF_INLINE_PAGE_THRESHOLD} pages / 20 MB), or extract text via Bash (e.g. \`pdftotext\`).`); } return `Error (Read): PDF page extraction failed for "${path}": ${extract.error}`; } const blocks = await pdfPagesToImageBlocks(extract.pages, path, downsamplerOpt); if (typeof blocks === "string") return blocks; const requestedPages = last - range.first + 1; const renderedAll = extract.pages.length >= requestedPages; const header = extract.timedOutAfterSec !== undefined ? renderedAll ? `[PDF: ${path} — pages ${range.first}-${last}${pageCount !== undefined ? ` of ${pageCount}` : ""}: pdftoppm timed out after ${extract.timedOutAfterSec}s, but all ${requestedPages} requested page${requestedPages === 1 ? "" : "s"} were rendered before the timeout hit at teardown (100 dpi); nothing is missing.]` : `[PDF: ${path} — pages ${range.first}-${last}${pageCount !== undefined ? ` of ${pageCount}` : ""}: pdftoppm timed out after ${extract.timedOutAfterSec}s; rendered ${extract.pages.length} of ${requestedPages} requested page${requestedPages === 1 ? "" : "s"} before the timeout (100 dpi). Read the remaining pages with a narrower range (e.g. pages: "${range.first + extract.pages.length}-${last}").]` : `[PDF: ${path} — pages ${range.first}-${last}${pageCount !== undefined ? ` of ${pageCount}` : ""} rendered as ${extract.pages.length} image${extract.pages.length === 1 ? "" : "s"} (100 dpi)]`; const content = [ { type: "text", text: header }, ...blocks, ]; return { content, details: { type: "document", file: { type: "application/pdf", originalSize: size, ...(pageCount !== undefined ? { pageCount } : {}), pages: `${range.first}-${last}`, renderedPages: extract.pages.length, ...(extract.timedOutAfterSec !== undefined ? { requestedPages, timedOut: true } : {}), }, }, }; } const pageCount = await getPdfPageCount(env, key, bytes, signal); if (pageCount !== undefined && pageCount > PDF_INLINE_PAGE_THRESHOLD) { return `Error (Read): This PDF has ${pageCount} pages, which is too many to read at once. Use the pages parameter to read specific page ranges (e.g., pages: "1-5"). Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.`; } if (size > PDF_TARGET_RAW_SIZE || bytes === undefined) { return `Error (Read): PDF file exceeds maximum allowed size of 20.0 MB (${size} bytes). Use the pages parameter to read specific page ranges (e.g., pages: "1-5"), or extract text via Bash (e.g. \`pdftotext\`).`; } if (!cap.document) { const fileCard = { type: "application/pdf", originalSize: size, ...(pageCount !== undefined ? { pageCount } : {}) }; const t = await extractPdfTextLayer(env, key, undefined, signal); if (t.ok) { const { body, truncated } = boundPdfExtractedText(t.text); const timeoutNote = t.timedOutAfterSec !== undefined ? `; PARTIAL — pdftotext timed out after ${t.timedOutAfterSec}s, so the text below covers only what was extracted before the timeout; use the pages parameter for the rest` : ""; return { content: [ { type: "text", text: `[PDF: ${path} — extracted text layer via pdftotext${pageCount !== undefined ? `; ${pageCount} page${pageCount === 1 ? "" : "s"}` : ""} (the serving model does not support native PDF input)${timeoutNote}]` + `\n${delimitUntrusted("PDF text layer", body)}${truncated ? `\n[extracted text truncated at ${MAX_READ_BYTES} bytes; use the pages parameter to read specific ranges]` : ""}${pdfDisclosureTrailer(body, disclosure)}`, }, ], details: { type: "document", file: { ...fileCard, ...(t.timedOutAfterSec !== undefined ? { timedOut: true } : {}) }, fallback: { level: 1, reason: "model lacks document input; text layer extracted via pdftotext" }, }, }; } const textWhy = t.toolMissing ? "`pdftotext` is not available in this environment" : `pdftotext failed: ${t.error}`; let renderWhy; if (cap.vision) { const n = Math.min(PDF_FALLBACK_RENDER_PAGES, pageCount ?? PDF_FALLBACK_RENDER_PAGES); const extract = await extractPdfPagesAsImages(env, key, 1, n, signal); if (extract.ok) { const blocks = await pdfPagesToImageBlocks(extract.pages, path, downsamplerOpt); if (typeof blocks !== "string") { const renderTimeoutNote = extract.timedOutAfterSec !== undefined ? ` pdftoppm timed out after ${extract.timedOutAfterSec}s — only the pages that finished rendering are included.` : ""; return { content: [ { type: "text", text: `[PDF: ${path} — first ${extract.pages.length}${pageCount !== undefined ? ` of ${pageCount}` : ""} page${extract.pages.length === 1 ? "" : "s"} rendered as image${extract.pages.length === 1 ? "" : "s"} (100 dpi; ${textWhy}).${renderTimeoutNote} ` + `Use the pages parameter (e.g., pages: "${extract.pages.length + 1}-${extract.pages.length + PDF_FALLBACK_RENDER_PAGES}") to read more pages.]`, }, ...blocks, ], details: { type: "document", file: { ...fileCard, pages: `1-${extract.pages.length}`, renderedPages: extract.pages.length, ...(extract.timedOutAfterSec !== undefined ? { timedOut: true } : {}) }, fallback: { level: 2, reason: `model lacks document input; ${textWhy}; rendered first ${extract.pages.length} page(s) via pdftoppm` }, }, }; } renderWhy = "a rendered page image exceeded the per-image API size limit"; } else { renderWhy = extract.toolMissing ? "the poppler utility `pdftoppm` is not available in this environment" : `pdftoppm failed: ${extract.error}`; } } else { renderWhy = "the serving model does not support image input, so pages cannot be rendered"; } const why = `text extraction did not run (${textWhy}); page rendering did not run (${renderWhy})`; return { content: [ { type: "text", text: `[PDF document omitted: the serving model does not support native PDF (document) input, and engine-side fallbacks were unavailable — ${why}. ` + `Install poppler-utils, or extract the text via Bash (e.g. \`pdftotext -layout "${path}" -\`).]`, }, ], details: { type: "document", file: fileCard, fallback: { level: 3, reason: why } }, }; } const base64 = Buffer.from(bytes).toString("base64"); return { content: [{ type: "document", data: base64, mimeType: "application/pdf" }], details: { type: "document", file: { ...(base64.length <= 48_000 ? { base64 } : {}), type: "application/pdf", originalSize: size, ...(pageCount !== undefined ? { pageCount } : {}) }, }, }; }