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
+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(