@sema-agent/core
Version:
Stateless, task-oriented AI agent core
242 lines (241 loc) • 15.6 kB
JavaScript
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 } : {}) },
},
};
}