researcher: PDFs from the web — the pdf-reader's text tools in the loop (design B)
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: <final url after redirects> (HTTP <status>)' — 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.
This commit is contained in:
@@ -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<string> {
|
||||
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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof pdfInfo>>): 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<string, AgentTool<any>> {
|
||||
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<PdfFetchSummary | string> => {
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -91,8 +91,17 @@ async function fetchAndExtract(url: string): Promise<string> {
|
||||
});
|
||||
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<string> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user