Learner source files, on the teacher's own terms (nothing auto-injects): - read tool (src/agent/tools/read.ts): pi-read port — path/offset/limit, 2000-line/50KB truncation with use-offset continuation notices, image files (jpg/png/gif/webp/bmp) returned as native image content parts (new LhToolImageResult escape hatch in lh-tool.ts). Always-on in both the prober and teacher registries (user-decided 2026-09). - pdf-reader sub-agent (JobKind 'pdf', toggleable, ack-and-steer): triage via pdf_info (pdfjs text layer: per-page text/image/math-density), targeted pdf_extract, regex pdf_search (hits count as consultation), and pdf_render — pages painted by pdfjs onto a canvas shim in headless Chrome (the pdfium viewer doesn't paint headless), screenshot fed back image-in-the-loop (visual-maker idiom, providerSafePng applied). The unread-finish gate bounces a zero-consultation brief once. Teacher gets a text brief with page citations only; renders never leave the loop. - MODELS.pdfReader = qwen/qwen3.8-flash (user override 2026-09-19: the look-at-renders workload wants the vision-proven model; catalog listed glm-5.3-flash as image-capable but qwen is the known-good daily driver). - UI: PdfCompleteBadge + PdfBriefCard (steer-card, job-card), dispatch card + activity-dock kind entries (type-forced, flagged for UI review). - Prompts: 'Learner source files' section in teacher/30-jobs.md; read line in prober/10-probe-doctrine.md. Doctrine rides tool descriptions per the self-contained-tools principle.
154 lines
6.0 KiB
TypeScript
154 lines
6.0 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.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.pagesConsulted, [1]);
|
|
// 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.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.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.pagesConsulted, []);
|
|
});
|
|
}); |