Live-smoke follow-up the user approved (2026-09-19): the GEB read showed
"78 pages consulted" when the reader genuinely READ ~12 - pdf_search hit
snippets inflated the count. PdfResult now carries both:
- pagesRead: extract/render output entered the loop - the honest count
the badge and brief footer show ("read N pages", "Pages read: ...").
- pagesConsulted: every page whose content entered the loop, search-hit
context lines included - unchanged, still the citation audit set (the
reader's instructions still say "Pages consulted: N, N, ..." and the
footer still lists it when it exceeds the read set).
The unread-finish gate deliberately stays on CONSULTED: search hits are
genuine consultation (anti-fabrication duty), so a search-only brief is
honest and must deliver - pagesRead is a reporting refinement, not a
delivery gate. Pinned by the search-only faux test (read [], consulted
[1], delivered).
UI tolerates pre-split persisted records via ?? fallbacks (old steers
lack pagesRead).
160 lines
6.4 KiB
TypeScript
160 lines
6.4 KiB
TypeScript
/**
|
|
* The pdf-reader sub-agent (sources v0) — the hand-rolled loop on a faux
|
|
* engine: triage→extract→brief flow, the unread-finish doctrine gate, the
|
|
* render path via the headless fake, and the unreadable-source failure.
|
|
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import * as path from "node:path";
|
|
import { describe, it } from "node:test";
|
|
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
|
import type { PiEngine } from "../src/agent/models.ts";
|
|
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
|
|
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
|
|
|
|
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
|
|
|
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
|
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
|
|
faux.setResponses(steps);
|
|
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
|
}
|
|
|
|
const PNG_1PX = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
0x42, 0x60, 0x82,
|
|
]);
|
|
|
|
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } {
|
|
const calls: string[] = [];
|
|
return {
|
|
calls,
|
|
available: () => true,
|
|
unavailableReason: () => null,
|
|
runDemo: async () => {
|
|
throw new Error("unused");
|
|
},
|
|
renderSvg: async () => {
|
|
throw new Error("unused");
|
|
},
|
|
renderPdfPages: async (_pdfPath, pages) => {
|
|
calls.push(`render:${pages.join(",")}`);
|
|
rendered.pages.push(...pages);
|
|
return pages.map((page) => ({ page, png: PNG_1PX }));
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("pdf-reader loop (faux engine)", () => {
|
|
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
|
|
const engine = fauxEngine([
|
|
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
|
|
fauxAssistantMessage(
|
|
[fauxToolCall("pdf_extract", { pages: "1-2" }, { id: "e1" })],
|
|
{ stopReason: "toolUse" },
|
|
),
|
|
fauxAssistantMessage([fauxText("The integral rule appears on page 1 (p. 1). Pages consulted: 1, 2")]),
|
|
]);
|
|
const result = await runPdfReaderAgent(
|
|
{ path: FIXTURE, question: "what is the integral of x?" },
|
|
{ engine },
|
|
);
|
|
assert.equal(result.path, FIXTURE);
|
|
assert.match(result.brief, /integral rule/);
|
|
assert.deepEqual(result.pagesRead, [1, 2], "extracted pages are read pages");
|
|
assert.deepEqual(result.pagesConsulted, [1, 2]);
|
|
});
|
|
|
|
it("the unread-finish gate: a brief with zero consultation is bounced, not delivered", async () => {
|
|
let nudged = false;
|
|
const engine = fauxEngine([
|
|
// first finish attempt with NOTHING read — the loop must inject the
|
|
// nudge and keep going rather than deliver invention
|
|
fauxAssistantMessage([fauxText("The answer is 42, trust me.")]),
|
|
// after the nudge: consult, then brief honestly
|
|
fauxAssistantMessage(
|
|
[fauxToolCall("pdf_search", { query: "integral", literal: false }, { id: "s1" })],
|
|
{ stopReason: "toolUse" },
|
|
),
|
|
fauxAssistantMessage([fauxText("Found it on page 1 (p. 1): the integral of x dx.")]),
|
|
]);
|
|
const result = await runPdfReaderAgent(
|
|
{ path: FIXTURE, question: "integral of x?" },
|
|
{ engine, onActivity: (line) => void (line && (nudged ||= line.startsWith("pdf_search"))) },
|
|
);
|
|
assert.match(result.brief, /Found it on page 1/);
|
|
assert.deepEqual(result.pagesRead, [], "search hits consult but do not read (the split)");
|
|
assert.deepEqual(result.pagesConsulted, [1]);
|
|
// and the gate lets it through: search hits ARE genuine consultation
|
|
// the first (invention) answer must NOT be the delivered brief
|
|
assert.notEqual(result.brief, "The answer is 42, trust me.");
|
|
});
|
|
|
|
it("the render path: images ride a user message after tool results; pages counted", async () => {
|
|
const headless = fakeHeadless();
|
|
const engine = fauxEngine([
|
|
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "2-3", dpi: 150 }, { id: "r1" })], {
|
|
stopReason: "toolUse",
|
|
}),
|
|
fauxAssistantMessage([fauxText("The physics formula is visible on page 2 (p. 2).")]),
|
|
]);
|
|
const result = await runPdfReaderAgent(
|
|
{ path: FIXTURE, question: "what is the force formula?" },
|
|
{ engine, headless },
|
|
);
|
|
assert.deepEqual(result.pagesRead, [2, 3], "rendered pages are read pages");
|
|
assert.deepEqual(result.pagesConsulted, [2, 3]);
|
|
assert.deepEqual(headless.calls, ["render:2,3"]);
|
|
});
|
|
|
|
it("no Chrome: render calls degrade to an error note, text tools still work", async () => {
|
|
const engine = fauxEngine([
|
|
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "r1" })], {
|
|
stopReason: "toolUse",
|
|
}),
|
|
// the reader adapts after the error: extract instead, then brief
|
|
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "1" }, { id: "e1" })], {
|
|
stopReason: "toolUse",
|
|
}),
|
|
fauxAssistantMessage([fauxText("Extracted instead: the calculus line (p. 1).")]),
|
|
]);
|
|
const result = await runPdfReaderAgent(
|
|
{ path: FIXTURE, question: "page 1 content?" },
|
|
{
|
|
engine,
|
|
headless: {
|
|
available: () => false,
|
|
unavailableReason: () => "No Chrome found",
|
|
runDemo: async () => {
|
|
throw new Error("unused");
|
|
},
|
|
renderSvg: async () => {
|
|
throw new Error("unused");
|
|
},
|
|
renderPdfPages: async () => {
|
|
throw new Error("should not render");
|
|
},
|
|
},
|
|
},
|
|
);
|
|
assert.match(result.brief, /Extracted instead/);
|
|
assert.deepEqual(result.pagesRead, [1]);
|
|
assert.deepEqual(result.pagesConsulted, [1]);
|
|
});
|
|
|
|
it("an unreadable source fails deterministically before any model call", async () => {
|
|
const engine = fauxEngine([fauxAssistantMessage([fauxText("never reached")])]);
|
|
const result = await runPdfReaderAgent(
|
|
{ path: path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt"), question: "anything" },
|
|
{ engine },
|
|
);
|
|
// had the model been consulted, the brief would be its text instead
|
|
assert.match(result.brief, /^PDF failed:/);
|
|
assert.ok(!result.brief.includes("never reached"));
|
|
assert.deepEqual(result.pagesRead, []);
|
|
assert.deepEqual(result.pagesConsulted, []);
|
|
});
|
|
}); |