feat(T2b): sources v0 — read tool + pdf-reader sub-agent
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.
This commit is contained in:
+42
-3
@@ -42,7 +42,7 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
||||
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
|
||||
import { JobCard } from "../components/artifacts/job-card.tsx";
|
||||
import { JobsContext } from "../components/jobs-context.ts";
|
||||
import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts";
|
||||
import { demoInputSchema, jobTools, pdfInputSchema, visualInputSchema } from "../src/agent/tools/jobs.ts";
|
||||
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
@@ -174,6 +174,9 @@ function noChromeHeadless(): HeadlessApi {
|
||||
renderSvg: async () => {
|
||||
throw new Error("should not render");
|
||||
},
|
||||
renderPdfPages: async () => {
|
||||
throw new Error("should not render");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,6 +372,39 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
assert.ok(html.includes("britannica.com/plato"), "source links ride inside the details");
|
||||
});
|
||||
|
||||
it("renders a quiet PDF-reading-complete one-liner (sources v0) whose collapsed details carries the brief + pages", () => {
|
||||
const steer = buildSubagentSteer(
|
||||
{ id: "jpf", kind: "pdf", toolCallId: "cpf" },
|
||||
{
|
||||
path: "/Users/x/course/chapter-3.pdf",
|
||||
question: "What is the integral of x dx?",
|
||||
brief: "The power rule appears on **page 1**: the integral of x dx is x^2/2 + C.",
|
||||
pagesConsulted: [1, 2],
|
||||
},
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /PDF/);
|
||||
assert.match(html, /reading complete/);
|
||||
assert.match(html, /2 pages consulted/);
|
||||
assert.match(html, /<details[^>]*data-testid="pdf-complete-badge"/, "a real disclosure like the research badge");
|
||||
assert.ok(!/<details[^>]*open/.test(html), "collapsed by default");
|
||||
assert.ok(html.includes("power rule"), "the brief rides inside the details");
|
||||
// the path/question are sub-agent context — never learner-facing chrome
|
||||
assert.ok(!html.includes("/Users/x/course/chapter-3.pdf"), "the file path never renders");
|
||||
});
|
||||
|
||||
it("renders the PDF failure brief with the failed badge when the reader FAILED", () => {
|
||||
const steer = buildSubagentSteer(
|
||||
{ id: "jpf2", kind: "pdf", toolCallId: "cpf2" },
|
||||
{ path: "/tmp/doc.pdf", question: "q", brief: "PDF failed: the file could not be opened", pagesConsulted: [] },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /pdf-brief-card/);
|
||||
assert.match(html, /failed/);
|
||||
assert.match(html, /could not be opened/);
|
||||
assert.ok(!html.includes("pdf-complete-badge"), "no complete badge for a failure");
|
||||
});
|
||||
|
||||
it("renders the visual failure note with no svg when the build FAILED", () => {
|
||||
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
@@ -750,14 +786,17 @@ describe("tool + renderer registry wiring", () => {
|
||||
it("demo and visual are SERVER-EXECUTED dispatch tools with the contract schemas", () => {
|
||||
const { runner } = testRunner();
|
||||
const tools = jobTools({ chatId: "chat_registry", jobStore, spawn: (job) => void runner.runJob(job.id) });
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["demo", "research", "visual"]);
|
||||
for (const name of ["demo", "visual", "research"] as const) {
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["demo", "pdf", "research", "visual"]);
|
||||
for (const name of ["demo", "visual", "research", "pdf"] as const) {
|
||||
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`);
|
||||
}
|
||||
assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true);
|
||||
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
|
||||
assert.equal(visualInputSchema.safeParse({ brief: "b" }).success, true);
|
||||
assert.equal(visualInputSchema.safeParse({}).success, false, "visual requires a brief");
|
||||
assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf", question: "q" }).success, true);
|
||||
assert.equal(pdfInputSchema.safeParse({ question: "q" }).success, false, "pdf requires a path");
|
||||
assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf" }).success, false, "pdf requires a question");
|
||||
});
|
||||
|
||||
it("the artifact renderer registry maps demo/visual to the W6 cards", () => {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
this is a text file, not a pdf
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 9 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 89 >>
|
||||
stream
|
||||
BT /F1 14 Tf 72 720 Td (Calculus page: the integral of x dx is x squared over two) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Length 82 >>
|
||||
stream
|
||||
BT /F1 14 Tf 72 720 Td (Physics page: force equals mass times acceleration) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
7 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R /Resources << /Font << /F1 9 0 R >> >> >>
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Length 60 >>
|
||||
stream
|
||||
BT /F1 14 Tf 72 720 Td (Chemistry page: H2O is water) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000127 00000 n
|
||||
0000000253 00000 n
|
||||
0000000391 00000 n
|
||||
0000000517 00000 n
|
||||
0000000648 00000 n
|
||||
0000000774 00000 n
|
||||
0000000883 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R >>
|
||||
startxref
|
||||
953
|
||||
%%EOF
|
||||
+118
-1
@@ -37,7 +37,9 @@ import {
|
||||
steerDelivered,
|
||||
type DemoResult,
|
||||
type JobUsage,
|
||||
type PdfResult,
|
||||
type ResearchResult,
|
||||
type SubagentArtifactData,
|
||||
type SteerMessage,
|
||||
type SubagentSteerMessage,
|
||||
type VisualResult,
|
||||
@@ -55,7 +57,7 @@ import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { createJobRunner, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import { createJobRunner, pdfModelOverride, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
@@ -288,6 +290,24 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
}
|
||||
assert.ok(failResearch.parts[0].type === "text" && failResearch.parts[0].text.includes("flag the uncertainty"));
|
||||
});
|
||||
|
||||
it("pdf steers (sources v0): brief + page citations on success, PDF failed: shape on failure, abort wording per kind", () => {
|
||||
const pdf: PdfResult = { path: "/tmp/doc.pdf", question: "q?", brief: "The formula is on p. 2.", pagesConsulted: [1, 2] };
|
||||
const ok = buildSubagentSteer({ id: "jp", kind: "pdf", toolCallId: "cp" }, pdf);
|
||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("page citations"), "the teacher is told to cite pages");
|
||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("The formula is on p. 2."));
|
||||
assert.equal(ok.parts[1].type, "data-subagent-artifact");
|
||||
assert.equal((ok.parts[1] as { data: SubagentArtifactData }).data.kind, "pdf");
|
||||
assert.deepEqual((ok.parts[1] as { data: SubagentArtifactData }).data.result, pdf);
|
||||
// failure shape mirrors research
|
||||
assert.equal(isSubagentFailure("pdf", pdf), false);
|
||||
assert.equal(isSubagentFailure("pdf", { ...pdf, brief: "PDF failed: unreadable" }), true);
|
||||
const fail = buildSubagentSteer({ id: "jf", kind: "pdf", toolCallId: "cf" }, { ...pdf, brief: "PDF failed: unreadable" });
|
||||
assert.ok(fail.parts[0].type === "text" && fail.parts[0].text.includes("flag the gap"));
|
||||
// abort wording names the PDF job
|
||||
const aborted = buildAbortSteer({ id: "ja", kind: "pdf", toolCallId: "ca" });
|
||||
assert.ok(aborted.parts[0].type === "text" && aborted.parts[0].text.includes("PDF reading job"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fake deliver (the wake pipeline's stand-in for runner tests) ───────────
|
||||
@@ -558,6 +578,87 @@ describe("research runner (async protocol §3)", () => {
|
||||
|
||||
// ─── the researcher agent construction (model resolution seam) ──────────────
|
||||
|
||||
|
||||
describe("pdf runner (sources v0)", () => {
|
||||
const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
const PDF_INPUT = { path: PDF_FIXTURE, question: "What is the integral of x dx?" };
|
||||
|
||||
/** A pdf-reader that triages, extracts pages 1-2, then briefs. */
|
||||
function pdfReaderModel(): PiEngine {
|
||||
return 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): x dx integrates to x^2/2.")]),
|
||||
]);
|
||||
}
|
||||
|
||||
it("runJob completes a pdf job: brief + pagesConsulted land on the record, citation steer delivered", async () => {
|
||||
const chat = chatStore.createChat("Test: pdf runner");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "read this pdf" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-pdf", toolCallId: "call-pdf1", state: "output-available", input: PDF_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf1" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
pdfModelOverride.current = pdfReaderModel();
|
||||
try {
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
pdfModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered");
|
||||
const result = done.result as PdfResult;
|
||||
assert.equal(result.path, PDF_FIXTURE);
|
||||
assert.match(result.brief, /integral rule/);
|
||||
assert.deepEqual(result.pagesConsulted, [1, 2]);
|
||||
|
||||
assert.equal(delivery.steers.length, 1, "one job, one steer");
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.equal(steer.metadata.subagentResult.kind, "pdf");
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("integral rule"), "steer text carries the brief");
|
||||
});
|
||||
|
||||
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
|
||||
const chat = chatStore.createChat("Test: pdf runner failure");
|
||||
trackChat(chat.id);
|
||||
const badPath = path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt");
|
||||
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf2" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
pdfModelOverride.current = pdfReaderModel();
|
||||
try {
|
||||
await runner.runJob(job.id, { path: badPath, question: "anything" });
|
||||
} finally {
|
||||
pdfModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "error", "the failure brief is a subagent failure");
|
||||
const result = done.result as PdfResult;
|
||||
assert.match(result.brief, /^PDF failed:/);
|
||||
assert.deepEqual(result.pagesConsulted, []);
|
||||
|
||||
const steer = delivery.steers[0]!;
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("flag the gap"), "the teacher learns to flag the gap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("researcher agent", () => {
|
||||
it("runs with the web tool set (no network touched)", () => {
|
||||
// The researcher is a runSubagent call over the webTools registry —
|
||||
@@ -568,6 +669,14 @@ describe("researcher agent", () => {
|
||||
// reset any accidental global override
|
||||
researchModelOverride.current = undefined;
|
||||
});
|
||||
|
||||
it("the pdf-reader declares exactly the four skill tools (sources v0)", async () => {
|
||||
const { createPdfTools } = await import("../src/agent/subagents/pdf-reader.ts");
|
||||
const tools = createPdfTools(path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"));
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["pdf_extract", "pdf_info", "pdf_render", "pdf_search"]);
|
||||
// reset any accidental global override
|
||||
pdfModelOverride.current = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. the wake pipeline (server-side steer delivery) ────────────────────────
|
||||
@@ -972,6 +1081,14 @@ describe("prompt composition", () => {
|
||||
assert.ok(!prober.includes("Question principles"), "the teach-phase fragment never composes into the prober");
|
||||
});
|
||||
|
||||
it("the sources-v0 prompt lines compose: read/pdf doctrine in the teacher, the read line in the prober", () => {
|
||||
const teacher = composeInstructions("teacher");
|
||||
assert.ok(teacher.includes("Learner source files"), "the teacher carries the source-files section");
|
||||
assert.ok(teacher.includes("brief with page citations"), "the pdf brief's citation duty is stated");
|
||||
const prober = composeInstructions("prober");
|
||||
assert.ok(prober.includes("the `read` tool can inspect it directly"), "the prober knows it can read learner files");
|
||||
});
|
||||
|
||||
it("guards prompt size — no fragment can silently balloon (the README-leak class)", () => {
|
||||
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
|
||||
// teacher turn until the numeric-prefix filter stopped it. This guard
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* pdf-ops (sources v0) — the pdfjs text layer against a committed fixture
|
||||
* (tests/fixtures/sample-3page.pdf: 3 pages, distinct subjects, "force" on
|
||||
* page 2 only). The render path (headless Chrome) is covered by the
|
||||
* pdf-reader loop tests via the headlessOverride fake.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { parsePageSpec, pdfExtract, pdfInfo, pdfProbe, pdfSearch } from "../src/agent/subagents/pdf-ops.ts";
|
||||
|
||||
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
|
||||
describe("pdf-ops — the pdfjs text layer", () => {
|
||||
it("pdfInfo triages every page (count, text lengths, image counts, math density)", async () => {
|
||||
const info = await pdfInfo(FIXTURE);
|
||||
assert.equal(info.page_count, 3);
|
||||
assert.equal(info.pages.length, 3);
|
||||
assert.equal(info.pages[0]!.page, 1);
|
||||
assert.ok(info.pages.every((p) => p.text_length > 0));
|
||||
assert.ok(info.mean_text_length > 0);
|
||||
// fixture has no images
|
||||
assert.ok(info.pages.every((p) => p.image_count === 0));
|
||||
});
|
||||
|
||||
it("pdfExtract heads pages and honors the spec", async () => {
|
||||
const two = await pdfExtract(FIXTURE, "1-2");
|
||||
assert.match(two, /^--- Page 1 ---/);
|
||||
assert.match(two, /--- Page 2 ---/);
|
||||
assert.ok(!two.includes("--- Page 3 ---"), "page 3 not requested");
|
||||
assert.match(two, /Calculus/);
|
||||
const picks = await pdfExtract(FIXTURE, "1,3");
|
||||
assert.match(picks, /--- Page 1 ---/);
|
||||
assert.ok(!picks.includes("--- Page 2 ---"));
|
||||
assert.match(picks, /Chemistry/);
|
||||
});
|
||||
|
||||
it("pdfSearch finds hits with page/line + context, literal and regex", async () => {
|
||||
const lit = await pdfSearch(FIXTURE, "force", { literal: true });
|
||||
assert.equal(lit.hits.length, 1);
|
||||
assert.equal(lit.hits[0]!.page, 2);
|
||||
assert.match(lit.hits[0]!.context, />>> /);
|
||||
// regex with case-insensitivity
|
||||
const re = await pdfSearch(FIXTURE, "H2O|water");
|
||||
assert.equal(re.hits.length, 1);
|
||||
assert.equal(re.hits[0]!.page, 3);
|
||||
// no-hit silence
|
||||
const none = await pdfSearch(FIXTURE, "zymurgy", { literal: true });
|
||||
assert.equal(none.hits.length, 0);
|
||||
// invalid regex throws the skill's error shape
|
||||
await assert.rejects(() => pdfSearch(FIXTURE, "([unclosed"), /Invalid regex/);
|
||||
});
|
||||
|
||||
it("pdfProbe accepts the fixture and rejects garbage", async () => {
|
||||
await pdfProbe(FIXTURE); // resolves
|
||||
const garbage = path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt");
|
||||
await assert.rejects(() => pdfProbe(garbage));
|
||||
});
|
||||
|
||||
it("parsePageSpec: all / ranges / lists / clamping", () => {
|
||||
assert.deepEqual(parsePageSpec("all", 3), [1, 2, 3]);
|
||||
assert.deepEqual(parsePageSpec("1-5", 3), [1, 2, 3]);
|
||||
assert.deepEqual(parsePageSpec("1,3,7", 3), [1, 3]);
|
||||
assert.deepEqual(parsePageSpec("3", 3), [3]);
|
||||
assert.deepEqual(parsePageSpec(" 2 , 1 ", 3), [1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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, []);
|
||||
});
|
||||
});
|
||||
@@ -143,10 +143,10 @@ describe("the prober's quiz: no nodeId field at all", () => {
|
||||
assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated");
|
||||
});
|
||||
|
||||
it("the prober's tool registry carries the nodeId-less quiz and probe_done only", () => {
|
||||
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the shared read tool", () => {
|
||||
const chatId = newChat("Prober: registry");
|
||||
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz"]);
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
|
||||
const schema = quizSchemaOf(tools);
|
||||
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
|
||||
});
|
||||
@@ -154,25 +154,26 @@ describe("the prober's quiz: no nodeId field at all", () => {
|
||||
// ─── tool availability (2026-09 settings seam) ───────────────────────────────
|
||||
|
||||
describe("tool toggles (core/pending.ts + factories)", () => {
|
||||
it("TOGGLEABLE_TOOLS: today only the sub-agent dispatch tools; quiz/ask stay off the list", () => {
|
||||
it("TOGGLEABLE_TOOLS: the sub-agent dispatch tools; quiz/ask/read stay off the list", () => {
|
||||
// The user-toggleable vocabulary Platform's settings validates against.
|
||||
// quiz/ask_user_question are always-on FOR NOW (user-decided 2026-09-15);
|
||||
// they join when settings exposes them — the factory seam is generic.
|
||||
assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual"]);
|
||||
// quiz/ask_user_question/read are always-on FOR NOW (user-decided
|
||||
// 2026-09-15, sources v0); they join when settings exposes them — the
|
||||
// factory seam is generic.
|
||||
assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual", "pdf"]);
|
||||
});
|
||||
|
||||
it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => {
|
||||
const chatId = newChat("Prober: toggles");
|
||||
const ctx = { chatId, chatStore, deliver: () => {} };
|
||||
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "probe_done", "quiz"]);
|
||||
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
|
||||
|
||||
const noAsk = createProberTools(ctx, { ask_user_question: false });
|
||||
assert.deepEqual(Object.keys(noAsk).sort(), ["probe_done", "quiz"]);
|
||||
assert.deepEqual(Object.keys(noAsk).sort(), ["probe_done", "quiz", "read"]);
|
||||
|
||||
// quiz off is honored by the generic machinery too (future toggleable),
|
||||
// but probe_done is never droppable — it is how the phase ends.
|
||||
const noQuiz = createProberTools(ctx, { quiz: false });
|
||||
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "probe_done"]);
|
||||
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "probe_done", "read"]);
|
||||
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
|
||||
});
|
||||
|
||||
@@ -185,10 +186,10 @@ describe("tool toggles (core/pending.ts + factories)", () => {
|
||||
spawn: () => {},
|
||||
};
|
||||
const full = Object.keys(createTeacherTools(ctx)).sort();
|
||||
assert.deepEqual(full, ["ask_user_question", "demo", "plan_init", "quiz", "research", "visual"]);
|
||||
assert.deepEqual(full, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "research", "visual"]);
|
||||
|
||||
const noResearch = Object.keys(createTeacherTools(ctx, { research: false })).sort();
|
||||
assert.deepEqual(noResearch, ["ask_user_question", "demo", "plan_init", "quiz", "visual"]);
|
||||
assert.deepEqual(noResearch, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "visual"]);
|
||||
|
||||
// absent = enabled: an empty record and undefined behave identically
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The read tool (sources v0) — a Node port of pi's read semantics:
|
||||
* truncation with continuation offsets, image mime detection, error paths.
|
||||
* Plus the lh-tool.ts image-result escape hatch it exercises.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { readTool } from "../src/agent/tools/read.ts";
|
||||
import { toPiTool } from "../src/agent/lh-tool.ts";
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
|
||||
|
||||
function write(name: string, content: string): string {
|
||||
const file = path.join(tmp, name);
|
||||
fs.writeFileSync(file, content, "utf-8");
|
||||
return file;
|
||||
}
|
||||
|
||||
/** The 2-line/8-byte local config, applied the same way as the real caps. */
|
||||
const run = (args: { path: string; offset?: number; limit?: number }) =>
|
||||
(readTool.execute as (i: unknown, c: { toolCallId: string }) => Promise<unknown>)!(args, {
|
||||
toolCallId: "test",
|
||||
});
|
||||
|
||||
describe("read tool — text semantics", () => {
|
||||
it("reads a small file verbatim", async () => {
|
||||
const file = write("small.txt", "hello\nworld");
|
||||
assert.equal(await run({ path: file }), "hello\nworld");
|
||||
});
|
||||
|
||||
it("resolves relative paths against cwd", async () => {
|
||||
fs.writeFileSync(path.join(process.cwd(), "lh-read-rel-test.txt"), "rel", "utf-8");
|
||||
try {
|
||||
assert.equal(await run({ path: "lh-read-rel-test.txt" }), "rel");
|
||||
} finally {
|
||||
fs.unlinkSync(path.join(process.cwd(), "lh-read-rel-test.txt"));
|
||||
}
|
||||
});
|
||||
|
||||
it("honors offset (1-indexed) and limit", async () => {
|
||||
const body = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
const file = write("thirty.txt", body);
|
||||
const out = (await run({ path: file, offset: 10, limit: 3 })) as string;
|
||||
// pi parity: the explicit limit stopping short gets a remaining notice
|
||||
assert.match(out, /^line 10\nline 11\nline 12\n\n\[18 more lines in file\. Use offset=13 to continue\.\]$/);
|
||||
});
|
||||
|
||||
it("offset beyond EOF is an error the model sees", async () => {
|
||||
const file = write("tiny.txt", "one");
|
||||
await assert.rejects(() => run({ path: file, offset: 99 }), /beyond end of file/);
|
||||
});
|
||||
|
||||
it("a missing file throws (pi's error-result semantics)", async () => {
|
||||
await assert.rejects(() => run({ path: path.join(tmp, "nope.txt") }), /ENOENT/);
|
||||
});
|
||||
|
||||
it("truncates at 2000 lines with a use-offset continuation notice", async () => {
|
||||
const body = Array.from({ length: 3000 }, (_, i) => `l${i + 1}`).join("\n");
|
||||
const file = write("big.txt", body);
|
||||
const out = (await run({ path: file })) as string;
|
||||
assert.match(out, /Showing lines 1-2000 of 3000 \(2000 line limit\)/);
|
||||
assert.match(out, /Use offset=2001 to continue/);
|
||||
assert.ok(!out.includes("l2001\n"), "line 2001 must not ride along");
|
||||
});
|
||||
|
||||
it("truncates at 50KB with the same continuation notice", async () => {
|
||||
// 200 lines of ~400B = ~80KB total, under the line cap — byte cap fires
|
||||
const line = "x".repeat(400);
|
||||
const body = Array.from({ length: 200 }, () => line).join("\n");
|
||||
const file = write("wide.txt", body);
|
||||
const out = (await run({ path: file })) as string;
|
||||
assert.match(out, /50\.0KB limit\)\. Use offset=\d+ to continue\./);
|
||||
// and the truncation actually cut content
|
||||
assert.ok(out.length < 52_000, "the 50KB ceiling must cut the 80KB body");
|
||||
});
|
||||
|
||||
it("an explicit limit that stops short gets a remaining-lines notice", async () => {
|
||||
const body = Array.from({ length: 10 }, (_, i) => `l${i + 1}`).join("\n");
|
||||
const file = write("ten.txt", body);
|
||||
const out = (await run({ path: file, limit: 4 })) as string;
|
||||
assert.match(out, /\[6 more lines in file\. Use offset=5 to continue\.\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("read tool — image semantics", () => {
|
||||
it("returns the LhToolImageResult escape-hatch shape for image files", async () => {
|
||||
const file = path.join(tmp, "dot.png");
|
||||
// minimal valid PNG header bytes are enough — detection is by extension
|
||||
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
const out = (await run({ path: file })) as { text: string; images?: { data: string; mimeType: string }[] };
|
||||
assert.match(out.text, /Read image file \[image\/png\]/);
|
||||
assert.equal(out.images?.length, 1);
|
||||
assert.equal(out.images![0]!.mimeType, "image/png");
|
||||
assert.match(out.images![0]!.data, /^[A-Za-z0-9+/]+={0,2}$/);
|
||||
});
|
||||
|
||||
it("toPiTool maps the image result to native pi content parts", async () => {
|
||||
const pi = toPiTool(readTool);
|
||||
const result = await pi.execute("call-1", { path: path.join(tmp, "dot.png") }, undefined as never);
|
||||
assert.ok(!("isError" in result) || !(result as { isError?: boolean }).isError);
|
||||
const content = (result as { content: Array<{ type: string }> }).content;
|
||||
assert.equal(content[0]!.type, "text");
|
||||
assert.equal(content[1]!.type, "image");
|
||||
});
|
||||
|
||||
it("toPiTool maps plain string results to a single text part (parity)", async () => {
|
||||
const file = write("plain.txt", "body");
|
||||
const pi = toPiTool(readTool);
|
||||
const result = await pi.execute("call-2", { path: file }, undefined as never);
|
||||
const content = (result as { content: Array<{ type: string }> }).content;
|
||||
assert.equal(content.length, 1);
|
||||
assert.equal(content[0]!.type, "text");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user