From 8dfe6217489eaabf6bbfef725d918c3d187a2e0a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 7 Oct 2026 22:36:11 +0300 Subject: [PATCH] =?UTF-8?q?researcher:=20PDFs=20from=20the=20web=20?= =?UTF-8?q?=E2=80=94=20the=20pdf-reader's=20text=20tools=20in=20the=20loop?= =?UTF-8?q?=20(design=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web_fetch_pdf downloads (25MB cap, probe-validated) into a per-job temp dir and returns the triage summary — page count, size class (the pdf doctrine's <=10/<=40 thresholds), flagged math/image-heavy pages. The researcher's toolset then gains pdf_info / pdf_extract / pdf_search over that dir, scoped by a resolve-and-startswith wall: the researcher's filesystem ends at the temp dir. Render is deliberately absent — the researcher slot has no vision; visual reading stays the pdf job's job. web_fetch: a PDF URL now returns a POINTER to web_fetch_pdf instead of dead-ending, and every output carries 'Source: (HTTP )' — the 'ill-formed request' lesson: a 200-served error body must be distinguishable from a real page. The runner owns the temp lifecycle (mkdtemp before, recursive cleanup in the job's finally — never enters the record) and web_fetch_pdf urls ride the sources ledger. Doctrine step 5 added: fetch, triage, extract only the pages that matter, cite as (p. N). Tests build a REAL minimal PDF programmatically (correct xref offsets — pdfjs parses it like any download): zero network, real parse. --- src/agent/subagents/researcher.ts | 16 ++- src/agent/subagents/runner.ts | 14 +- src/agent/tools/pdf-read.ts | 221 ++++++++++++++++++++++++++++++ src/agent/tools/web.ts | 15 +- tests/researcher-pdf.test.ts | 204 +++++++++++++++++++++++++++ 5 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 src/agent/tools/pdf-read.ts create mode 100644 tests/researcher-pdf.test.ts diff --git a/src/agent/subagents/researcher.ts b/src/agent/subagents/researcher.ts index 9d60f39..943d900 100644 --- a/src/agent/subagents/researcher.ts +++ b/src/agent/subagents/researcher.ts @@ -1,6 +1,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; import { createWebTools } from "../tools/web.ts"; +import { createPdfReadTools } from "../tools/pdf-read.ts"; import type { SearchProvider } from "../../core/search.ts"; import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; @@ -32,7 +33,11 @@ Process: 3. Read the results. Identify what's well-covered and what has gaps. If the first round doesn't fully answer the question, search again with refined queries targeting the gaps. 4. For the 2-3 most promising source URLs, use web_fetch to get the full page content. -5. Synthesize everything into a brief that directly answers the question. +5. PDF sources: when web_fetch says a URL is a PDF, call web_fetch_pdf on it — it downloads + and triages the document (page count, size class, flagged pages). Then read it with + pdf_extract — for long ones, pdf_search first and extract only the pages that hit. + Cite claims from a PDF as (p. N). +6. Synthesize everything into a brief that directly answers the question. Evaluation — what to keep vs drop: - Official docs and primary sources outweigh blog posts and forum threads. @@ -68,6 +73,11 @@ export interface ResearcherRunOptions { * seam reads as unavailable: the server wiring always injects one; the * dispatch-level drop (jobTools) keeps doomed jobs from even starting. */ search?: () => SearchProvider | null; + /** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the + * toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped + * to this per-job temp dir — PDFs from the web, read page-level. The + * runner owns its lifecycle (mkdtemp before, rm after). */ + pdfDir?: string; abortSignal?: AbortSignal; onUsage?: (usage: Usage) => void; onToolStart?: (toolName: string, args: unknown) => void; @@ -81,7 +91,9 @@ export interface ResearcherRunOptions { * the loop scripted. */ export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise { - const { text } = await runSubagent(Object.values(createWebTools(opts.search ?? (() => null))), { + const tools = Object.values(createWebTools(opts.search ?? (() => null))); + if (opts.pdfDir) tools.push(...Object.values(createPdfReadTools(opts.pdfDir))); + const { text } = await runSubagent(tools, { role: "researcher", prompt, engine: opts.engine, diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 5beb6f1..042796f 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -19,6 +19,9 @@ import { type VisualToolInput, } from "../../core/contracts.ts"; import { registerJobAbort, unregisterJobAbort } from "./aborts.ts"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import type { ModelRole } from "../../../config.ts"; import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts"; import type { ChatStore, JobStore } from "../../core/ports.ts"; @@ -353,20 +356,25 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { .join("\n\n"); const fetchedUrls: string[] = []; + // Per-job PDF dir (researcher PDF wave, 2026-10-07): web_fetch_pdf + // downloads land here; every pdf tool is scoped to it; it dies with + // the job (never enters the record). + const pdfDir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-research-")); try { jobStore.updateJob(jobId, { activity: "researching" }); const brief = await runResearcherAgent(prompt, { engine: model, search: deps.searchProvider, + pdfDir, abortSignal: loop.abortSignal, chatId: loop.chatId, // Money channel: each agent step is one model call — fold its // usage into the record as it completes (progress write). onUsage: (usage) => loop.onUsage(piUsageToStep(usage)), - onToolStart: (toolName, args) => { + onToolStart: (toolName, args) => { const input = args as { query?: string; url?: string } | undefined; - if (toolName === "web_fetch" && typeof input?.url === "string") { + if (typeof input?.url === "string" && (toolName === "web_fetch" || toolName === "web_fetch_pdf")) { fetchedUrls.push(input.url); } jobStore.updateJob(jobId, { activity: activityLine(toolName, input) }); @@ -383,6 +391,8 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { const reason = err instanceof Error ? err.message : String(err); if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason }); await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] }); + } finally { + await fs.rm(pdfDir, { recursive: true, force: true }); } } diff --git a/src/agent/tools/pdf-read.ts b/src/agent/tools/pdf-read.ts new file mode 100644 index 0000000..6fe526a --- /dev/null +++ b/src/agent/tools/pdf-read.ts @@ -0,0 +1,221 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; +import { pdfExtract, pdfInfo, pdfProbe, pdfSearch } from "../subagents/pdf-ops.ts"; + +/** + * PDF reading tools for the RESEARCHER sub-agent (owner design B, + * 2026-10-07): PDFs from the web, with the pdf-reader agent's TEXT + * capabilities — triage (pdf_info), page-level extraction (pdf_extract), + * targeted lookup (pdf_search) — woven into the researcher's own loop so + * page-cited content lands in the brief directly. + * + * web_fetch_pdf acquires the file (download, size-capped, probe-validated) + * into a per-job temp dir; every path-taking tool is SCOPED to that dir + * (the researcher can never read outside it). pdf_render is deliberately + * absent: the researcher's model slot has no vision — visual reading of + * math-heavy pages stays the pdf job's job. + * + * Tool contract (the web.ts law): NEVER throw into the agent loop — every + * failure path returns a clear error STRING the researcher can adapt to. + */ + +const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +const FETCH_TIMEOUT_MS = 60_000; +/** PDFs run bigger than pages — arxiv papers routinely 5-20 MB. */ +const MAX_PDF_BYTES = 25 * 1024 * 1024; + +/** The pdf-reader doctrine's size classes, by page count. */ +function sizeClass(pageCount: number): "short" | "medium" | "long" { + if (pageCount <= 10) return "short"; + if (pageCount <= 40) return "medium"; + return "long"; +} + +/** Pages worth a look before any extraction — math- or image-heavy. */ +function flaggedPages(info: Awaited>): number[] { + return info.pages + .filter((p) => p.math_density >= 0.03 || p.image_count > 0) + .slice(0, 30) + .map((p) => p.page); +} + +/** Resolve a tool-supplied path and require it to live INSIDE the job's + * pdf dir — the researcher's filesystem ends at the temp dir's wall. */ +function scopedPath(dir: string, filePath: string): string | null { + const root = path.resolve(dir) + path.sep; + const resolved = path.resolve(dir, filePath); + if (!resolved.startsWith(root)) return null; + return resolved; +} + +/** Filename for a downloaded PDF — slug from the URL's last segment plus a + * unique suffix (two PDFs from different URLs never collide). */ +function pdfFileName(url: string): string { + try { + const last = new URL(url).pathname.split("/").filter(Boolean).pop() ?? "document"; + const slug = last.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(-80) || "document"; + const stem = slug.replace(/\.pdf$/i, ""); + return `${stem}-${Math.random().toString(36).slice(2, 8)}.pdf`; + } catch { + return `document-${Math.random().toString(36).slice(2, 8)}.pdf`; + } +} + +/** The download + triage summary the researcher sees from web_fetch_pdf. */ +export interface PdfFetchSummary { + path: string; + url: string; + page_count: number; + size_class: "short" | "medium" | "long"; + mean_text_length: number; + flagged_pages: number[]; +} + +export function createPdfReadTools(dir: string): Record> { + const pdfFetchInput = Type.Object({ + url: Type.String({ description: "The PDF URL to download" }), + }); + const pdfPathInput = Type.Object({ + path: Type.String({ description: "The local PDF path returned by web_fetch_pdf" }), + }); + const pdfExtractInput = Type.Object({ + path: Type.String({ description: "The local PDF path returned by web_fetch_pdf" }), + pages: Type.String({ + description: "Pages to extract: 'all', '1-5', '1,3,7', or '3'. Use pdf_info first; narrow the spec for big documents.", + }), + }); + const pdfSearchInput = Type.Object({ + path: Type.String({ description: "The local PDF path returned by web_fetch_pdf" }), + query: Type.String({ description: "Text to find (case-insensitive substring)" }), + }); + + return { + web_fetch_pdf: { + name: "web_fetch_pdf", + label: "web_fetch_pdf", + description: + "Download a PDF from the web and triage it: returns its local path, page count, size class " + + "(short ≤10 pages, medium ≤40, long), and flagged (math/image-heavy) pages. " + + "Then read it with pdf_extract / pdf_search. Use when web_fetch reports a URL is a PDF.", + parameters: pdfFetchInput, + execute: serverExecute("web_fetch_pdf", pdfFetchInput, async ({ url }): Promise => { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") + throw new Error("Only http(s) URLs are supported"); + } catch { + return `web_fetch_pdf failed: invalid URL "${url}"`; + } + try { + const resp = await fetch(url, { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/pdf,application/octet-stream,*/*", + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + const contentType = resp.headers.get("content-type") ?? ""; + if (!/application\/pdf/i.test(contentType) && !/\.pdf(\?|$)/i.test(parsedPathname(url))) { + throw new Error(`Not a PDF (content-type ${contentType.split(";")[0] || "unknown"})`); + } + const declared = Number(resp.headers.get("content-length") ?? 0); + if (declared > MAX_PDF_BYTES) throw new Error("PDF too large (over 25 MB)"); + const bytes = new Uint8Array(await resp.arrayBuffer()); + if (bytes.length === 0) throw new Error("Empty response"); + if (bytes.length > MAX_PDF_BYTES) throw new Error("PDF too large (over 25 MB)"); + const filePath = path.join(dir, pdfFileName(url)); + await fs.writeFile(filePath, bytes); + try { + await pdfProbe(filePath); + } catch { + await fs.rm(filePath, { force: true }); + throw new Error("The download is not a readable PDF"); + } + const info = await pdfInfo(filePath); + const summary: PdfFetchSummary = { + path: filePath, + url, + page_count: info.page_count, + size_class: sizeClass(info.page_count), + mean_text_length: info.mean_text_length, + flagged_pages: flaggedPages(info), + }; + return summary; + } catch (err) { + return `web_fetch_pdf failed for ${url}: ${err instanceof Error ? err.message : String(err)}`; + } + }), + }, + + pdf_info: { + name: "pdf_info", + label: "pdf_info", + description: + "Triage a downloaded PDF: page count plus per-page signals (text_length, image_count, math_density).", + parameters: pdfPathInput, + execute: serverExecute("pdf_info", pdfPathInput, async ({ path: filePath }) => { + const scoped = scopedPath(dir, filePath); + if (!scoped) return `pdf_info failed: path must stay inside this job's download dir`; + try { + const info = await pdfInfo(scoped); + return { + page_count: info.page_count, + mean_text_length: info.mean_text_length, + size_class: sizeClass(info.page_count), + pages: info.pages, + }; + } catch (err) { + return `pdf_info failed: ${err instanceof Error ? err.message : String(err)}`; + } + }), + }, + + pdf_extract: { + name: "pdf_extract", + label: "pdf_extract", + description: + "Extract text from pages of a downloaded PDF (spec: 'all', '1-5', '1,3,7', '3'). " + + "Use pdf_info first; narrow the spec for big documents.", + parameters: pdfExtractInput, + execute: serverExecute("pdf_extract", pdfExtractInput, async ({ path: filePath, pages }) => { + const scoped = scopedPath(dir, filePath); + if (!scoped) return `pdf_extract failed: path must stay inside this job's download dir`; + try { + return await pdfExtract(scoped, pages); + } catch (err) { + return `pdf_extract failed: ${err instanceof Error ? err.message : String(err)}`; + } + }), + }, + + pdf_search: { + name: "pdf_search", + label: "pdf_search", + description: "Find text in a downloaded PDF — returns the pages and contexts that hit.", + parameters: pdfSearchInput, + execute: serverExecute("pdf_search", pdfSearchInput, async ({ path: filePath, query }) => { + const scoped = scopedPath(dir, filePath); + if (!scoped) return `pdf_search failed: path must stay inside this job's download dir`; + try { + const { hits } = await pdfSearch(scoped, query, { literal: true }); + return { hits }; + } catch (err) { + return `pdf_search failed: ${err instanceof Error ? err.message : String(err)}`; + } + }), + }, + }; +} + +function parsedPathname(url: string): string { + try { + return new URL(url).pathname; + } catch { + return ""; + } +} diff --git a/src/agent/tools/web.ts b/src/agent/tools/web.ts index 40aa357..a81fa81 100644 --- a/src/agent/tools/web.ts +++ b/src/agent/tools/web.ts @@ -91,8 +91,17 @@ async function fetchAndExtract(url: string): Promise { }); if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + // Final URL after redirects + status — the researcher's only way to + // tell a real page from a 200-served error body (owner-reported + // "ill-formed request" case: the SITE answered, we just relayed it). + const sourceLine = `Source: ${resp.url || url} (HTTP ${resp.status})`; + const contentType = resp.headers.get("content-type") ?? ""; - if (contentType.includes("application/pdf")) throw new Error("PDF documents are not supported"); + if (contentType.includes("application/pdf")) { + // Pointer, not dead-end (researcher PDF wave, 2026-10-07): the + // researcher's web_fetch_pdf tool downloads + triages PDFs. + return `web_fetch: this URL is a PDF document — call web_fetch_pdf on it to download and read it.`; + } if (/image\/|audio\/|video\/|zip|octet-stream/.test(contentType)) { throw new Error(`Unsupported content type: ${contentType.split(";")[0]}`); } @@ -100,11 +109,11 @@ async function fetchAndExtract(url: string): Promise { if (length > MAX_RESPONSE_BYTES) throw new Error("Response too large"); const text = await resp.text(); - if (!contentType.includes("html")) return truncate(text); // plain text / markdown / code + if (!contentType.includes("html")) return `${sourceLine}\n\n${truncate(text)}`; // plain text / markdown / code const extracted = htmlToMarkdown(text); if (extracted) { - const header = extracted.title ? `# ${extracted.title}\n\nSource: ${url}\n\n---\n\n` : ""; + const header = extracted.title ? `# ${extracted.title}\n\n${sourceLine}\n\n---\n\n` : `${sourceLine}\n\n---\n\n`; return header + truncate(extracted.content); } // Readability failed — likely JS-rendered. Try Jina Reader. diff --git a/tests/researcher-pdf.test.ts b/tests/researcher-pdf.test.ts new file mode 100644 index 0000000..3d76272 --- /dev/null +++ b/tests/researcher-pdf.test.ts @@ -0,0 +1,204 @@ +/** + * Researcher PDF capability (PDF wave, 2026-10-07, design B): web_fetch_pdf + * downloads a PDF into a per-job temp dir and returns the triage summary; + * pdf_info/pdf_extract/pdf_search read it page-level, ALL scoped to the dir + * (the researcher's filesystem ends at the temp wall). web_fetch gained the + * PDF POINTER (no dead-end) and the Source line with final URL + HTTP + * status (the "ill-formed request" lesson: 200-served error bodies must be + * distinguishable from real pages). + * + * Zero network: globalThis.fetch is stubbed with a REAL minimal PDF built + * programmatically (correct xref offsets — pdfjs parses it like any + * download). Rendering is out of scope here (no vision in the researcher). + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; +import { createPdfReadTools } from "../src/agent/tools/pdf-read.ts"; +import { createWebTools } from "../src/agent/tools/web.ts"; + +/** Build a valid N-page PDF in memory — ASCII-only so string offsets ARE + * byte offsets; xref entries computed as we assemble. */ +function buildPdf(pageTexts: string[]): Uint8Array { + const objects: string[] = []; // 1-indexed + const pageIds = pageTexts.map((_, i) => 3 + i * 2); + const fontId = 3 + pageTexts.length * 2; + objects[1] = "<>"; + objects[2] = `< `${id} 0 R`).join(" ")}]/Count ${pageTexts.length}>>`; + pageTexts.forEach((text, i) => { + const pageId = pageIds[i]!; + const contentId = pageId + 1; + const escaped = text.replace(/([()\\])/g, "\\$1"); + const stream = `BT /F1 12 Tf 72 720 Td (${escaped}) Tj ET`; + objects[pageId] = + `<>>>>>`; + objects[contentId] = `<>\nstream\n${stream}\nendstream`; + }); + objects[fontId] = "<>"; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + for (let id = 1; id < objects.length; id++) { + offsets[id] = pdf.length; + pdf += `${id} 0 obj\n${objects[id]}\nendobj\n`; + } + const xrefStart = pdf.length; + pdf += `xref\n0 ${objects.length}\n0000000000 65535 f \n`; + for (let id = 1; id < objects.length; id++) { + pdf += `${String(offsets[id]!).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<>\nstartxref\n${xrefStart}\n%%EOF\n`; + return new TextEncoder().encode(pdf); +} + +/** A fresh Response per call (bodies are single-use). */ +const pdfResponse = () => + new Response(PDF_BYTES.slice().buffer as ArrayBuffer, { headers: { "content-type": "application/pdf" } }); + +const PDF_BYTES = buildPdf([ + "Hello PDF page one — the triage summary should see this.", + "Page two holds the orthogonal complement theorem statement.", +]); + +const tempDirs: string[] = []; +async function newDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-test-pdf-")); + tempDirs.push(dir); + return dir; +} +after(async () => { + await Promise.all(tempDirs.map((d) => fs.rm(d, { recursive: true, force: true }))); +}); + +/** Tool-result .details → displayable string (the agent sees this shape). */ +function asText(result: unknown): string { + const r = result as { details?: unknown }; + if (typeof r?.details === "string") return r.details; + return JSON.stringify(r?.details ?? r); +} + +describe("researcher pdf tools — download + triage", () => { + it("web_fetch_pdf downloads, triages, and returns a usable summary", async () => { + const dir = await newDir(); + const tools = createPdfReadTools(dir); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + pdfResponse()) as typeof fetch; + try { + const out = await tools.web_fetch_pdf!.execute!("t1", { url: "https://arxiv.org/pdf/2406.14058" }); + const text = asText(out); + assert.match(text, /"page_count":2/); + assert.match(text, /"size_class":"short"/); + assert.match(text, /"path":"[^"]+\.pdf"/); + const summary = (out as { details: { path: string } }).details; + const written = await fs.readFile(summary.path); + assert.equal(written.byteLength, PDF_BYTES.byteLength, "the PDF bytes land on disk"); + } finally { + globalThis.fetch = realFetch; + } + }); + + it("the follow-up tools read the download page-level (extract + search)", async () => { + const dir = await newDir(); + const tools = createPdfReadTools(dir); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + pdfResponse()) as typeof fetch; + try { + const out = await tools.web_fetch_pdf!.execute!("t2", { url: "https://example.com/paper.pdf" }); + const { path: pdfPath } = (out as { details: { path: string } }).details; + + const extract = asText(await tools.pdf_extract!.execute!("t3", { path: pdfPath, pages: "2" })); + assert.match(extract, /--- Page 2 ---/); + assert.match(extract, /orthogonal complement/); + assert.ok(!extract.includes("page one"), "narrow specs extract only the asked pages"); + + const search = asText(await tools.pdf_search!.execute!("t4", { path: pdfPath, query: "theorem" })); + assert.match(search, /"page":2/); + } finally { + globalThis.fetch = realFetch; + } + }); + + it("rejects non-PDF responses and non-http(s) schemes with error strings", async () => { + const dir = await newDir(); + const tools = createPdfReadTools(dir); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("just a page", { headers: { "content-type": "text/html" } })) as typeof fetch; + try { + const notPdf = asText(await tools.web_fetch_pdf!.execute!("t5", { url: "https://example.com/doc" })); + assert.match(notPdf, /web_fetch_pdf failed/); + assert.match(notPdf, /Not a PDF/); + } finally { + globalThis.fetch = realFetch; + } + const ftp = asText(await tools.web_fetch_pdf!.execute!("t6", { url: "ftp://example.com/x.pdf" })); + assert.match(ftp, /invalid URL/); + }); +}); + +describe("researcher pdf tools — the temp-dir wall", () => { + it("path-taking tools refuse anything outside the job dir", async () => { + const dir = await newDir(); + const tools = createPdfReadTools(dir); + const outside = path.join(os.tmpdir(), "escape-attempt.pdf"); + const rel = asText(await tools.pdf_info!.execute!("t7", { path: outside })); + assert.match(rel, /pdf_info failed: path must stay inside/); + const traversal = asText(await tools.pdf_extract!.execute!("t8", { path: "../../etc/passwd", pages: "1" })); + assert.match(traversal, /pdf_extract failed: path must stay inside/); + }); + + it("pdf_info reports per-page triage signals for a real download", async () => { + const dir = await newDir(); + const tools = createPdfReadTools(dir); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + pdfResponse()) as typeof fetch; + try { + const out = await tools.web_fetch_pdf!.execute!("t9", { url: "https://example.com/x.pdf" }); + const { path: pdfPath } = (out as { details: { path: string } }).details; + const info = asText(await tools.pdf_info!.execute!("t10", { path: pdfPath })); + assert.match(info, /"page_count":2/); + assert.match(info, /"pages":\[/); + assert.match(info, /"text_length":/); + } finally { + globalThis.fetch = realFetch; + } + }); +}); + +describe("web_fetch — PDF pointer + source header", () => { + it("a PDF URL points at web_fetch_pdf instead of dead-ending", async () => { + const web = createWebTools(() => null); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + pdfResponse()) as typeof fetch; + try { + const out = asText(await web.web_fetch!.execute!("t11", { url: "https://example.com/paper.pdf" })); + assert.match(out, /PDF document/); + assert.match(out, /web_fetch_pdf/); + } finally { + globalThis.fetch = realFetch; + } + }); + + it("output carries the source line with HTTP status", async () => { + const web = createWebTools(() => null); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("A plain text page with enough content to matter.", { + headers: { "content-type": "text/plain" }, + })) as typeof fetch; + try { + const out = asText(await web.web_fetch!.execute!("t12", { url: "https://example.com/page" })); + assert.match(out, /^Source: https:\/\/example\.com\/page \(HTTP 200\)/); + } finally { + globalThis.fetch = realFetch; + } + }); +});