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:
amosblomqvist
2026-09-19 15:24:26 +03:00
parent bc2c02b865
commit 9a90dd51d9
27 changed files with 1970 additions and 55 deletions
+42 -3
View File
@@ -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", () => {
+1
View File
@@ -0,0 +1 @@
this is a text file, not a pdf
+54
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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]);
});
});
+154
View File
@@ -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, []);
});
});
+12 -11
View File
@@ -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(
+117
View File
@@ -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");
});
});