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:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIcon, BookOpenIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
import { ActivityIcon, BookOpenIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||||
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
|
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
|
||||||
@@ -232,6 +232,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
|||||||
research: SearchIcon,
|
research: SearchIcon,
|
||||||
demo: PlayIcon,
|
demo: PlayIcon,
|
||||||
visual: PenToolIcon,
|
visual: PenToolIcon,
|
||||||
|
pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf")
|
||||||
"graph-context": BookOpenIcon,
|
"graph-context": BookOpenIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,9 +348,11 @@ function ActivityBlobRow({
|
|||||||
? "Demo builder"
|
? "Demo builder"
|
||||||
: blob.jobKind === "visual"
|
: blob.jobKind === "visual"
|
||||||
? "Figure maker"
|
? "Figure maker"
|
||||||
: blob.jobKind === "graph-context"
|
: blob.jobKind === "pdf"
|
||||||
? "Graph context"
|
? "PDF reader"
|
||||||
: "Sub-agent";
|
: blob.jobKind === "graph-context"
|
||||||
|
? "Graph context"
|
||||||
|
: "Sub-agent";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={`activity-blob-${blob.process}`}
|
data-testid={`activity-blob-${blob.process}`}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const KIND_LABEL: Record<JobKind, string> = {
|
|||||||
research: "Research agent",
|
research: "Research agent",
|
||||||
demo: "Demo agent",
|
demo: "Demo agent",
|
||||||
visual: "Figure agent",
|
visual: "Figure agent",
|
||||||
|
pdf: "PDF reader",
|
||||||
// The injection is server-initiated — it has no tool part, so never a
|
// The injection is server-initiated — it has no tool part, so never a
|
||||||
// dispatch card. The entry exists only for the exhaustive record.
|
// dispatch card. The entry exists only for the exhaustive record.
|
||||||
"graph-context": "Graph context",
|
"graph-context": "Graph context",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import type { ArtifactPartProps, ResearchResult } from "@/src/core";
|
import type { ArtifactPartProps, PdfResult, ResearchResult } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The research tool-call box (contracts.ts "THE ASYNC PROTOCOL" §4).
|
* The research tool-call box (contracts.ts "THE ASYNC PROTOCOL" §4).
|
||||||
@@ -18,6 +18,39 @@ export function JobCard({ part }: ArtifactPartProps) {
|
|||||||
return <DispatchCard toolCallId={part.toolCallId} kind="research" />;
|
return <DispatchCard toolCallId={part.toolCallId} kind="research" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PDF reading brief (sources v0) — markdown body + the consulted-pages
|
||||||
|
* line. Exported: the steer card renders it under the PDF-complete badge,
|
||||||
|
* the same expand-on-demand idiom as research.
|
||||||
|
*/
|
||||||
|
export function PdfBriefCard({ output }: { output: unknown }) {
|
||||||
|
const result = output as PdfResult | undefined;
|
||||||
|
if (!result || typeof result.brief !== "string") {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
PDF reading completed, but the result could not be displayed.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const failed = result.brief.startsWith("PDF failed");
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="pdf-brief-card">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
PDF brief
|
||||||
|
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
||||||
|
<MessageResponse>{result.brief}</MessageResponse>
|
||||||
|
</div>
|
||||||
|
{!failed && result.pagesConsulted && result.pagesConsulted.length > 0 && (
|
||||||
|
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
||||||
|
Pages consulted: {result.pagesConsulted.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The research brief — markdown body + a small source link list. Exported:
|
* The research brief — markdown body + a small source link list. Exported:
|
||||||
* the steer card (sub-agent result delivery) renders the same artifact.
|
* the steer card (sub-agent result delivery) renders the same artifact.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, CircleX } from "lucide-react";
|
import { CheckIcon, CircleX } from "lucide-react";
|
||||||
import type { ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
|
import type { PdfResult, ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
|
||||||
import { isSubagentFailure } from "@/src/core";
|
import { isSubagentFailure } from "@/src/core";
|
||||||
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
||||||
import { ResearchBriefCard } from "@/components/artifacts/job-card";
|
import { PdfBriefCard, ResearchBriefCard } from "@/components/artifacts/job-card";
|
||||||
import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +48,12 @@ export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
|||||||
) : (
|
) : (
|
||||||
<ResearchCompleteBadge result={data.result as ResearchResult} />
|
<ResearchCompleteBadge result={data.result as ResearchResult} />
|
||||||
);
|
);
|
||||||
|
case "pdf":
|
||||||
|
return isSubagentFailure("pdf", data.result as PdfResult) ? (
|
||||||
|
<PdfBriefCard output={data.result} />
|
||||||
|
) : (
|
||||||
|
<PdfCompleteBadge result={data.result as PdfResult} />
|
||||||
|
);
|
||||||
case "demo":
|
case "demo":
|
||||||
return <DemoOutputCard output={data.result} />;
|
return <DemoOutputCard output={data.result} />;
|
||||||
case "visual":
|
case "visual":
|
||||||
@@ -68,6 +74,32 @@ function AbortedBadge({ kind }: { kind: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quiet one-liner, the research-badge idiom ported to PDF: the brief is
|
||||||
|
* too long to inline by default and the teacher's wake turn weaves it in;
|
||||||
|
* the badge marks delivery, the collapsed details carries the full text
|
||||||
|
* + consulted pages. */
|
||||||
|
function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
||||||
|
return (
|
||||||
|
<details data-testid="pdf-complete-badge" className="group w-full text-xs text-muted-foreground">
|
||||||
|
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
<span className="font-medium uppercase tracking-wide">PDF</span>
|
||||||
|
<span>reading complete</span>
|
||||||
|
{result.pagesConsulted.length > 0 && (
|
||||||
|
<span>
|
||||||
|
· {result.pagesConsulted.length} page{result.pagesConsulted.length === 1 ? "" : "s"} consulted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
|
||||||
|
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2">
|
||||||
|
<PdfBriefCard output={result} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Quiet one-liner, the dispatch-settled idiom — the lesson lives in the
|
/** Quiet one-liner, the dispatch-settled idiom — the lesson lives in the
|
||||||
* teacher's wake turn; the badge just marks delivery. The persisted brief
|
* teacher's wake turn; the badge just marks delivery. The persisted brief
|
||||||
* rides along in a collapsed <details> (the wave-1 "view full research"
|
* rides along in a collapsed <details> (the wave-1 "view full research"
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ export const MODELS = {
|
|||||||
demoBuilder: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
demoBuilder: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
||||||
/** Must be vision-capable: it looks at rendered SVGs. */
|
/** Must be vision-capable: it looks at rendered SVGs. */
|
||||||
visualMaker: { model: "qwen/qwen3.8-flash", effort: "low" },
|
visualMaker: { model: "qwen/qwen3.8-flash", effort: "low" },
|
||||||
/** SEAM (master-approved 2026-09): the pdf-reader sub-agent. Must be
|
/** SEAM (master-approved 2026-09, revised by the user 2026-09-19): the
|
||||||
* vision-capable — it looks at rendered PDF pages; its briefs are text.
|
* pdf-reader sub-agent. Must be vision-capable — it looks at rendered
|
||||||
* The teacher/researcher family model (glm-5.3-flash accepts image input
|
* PDF pages; its briefs are text. The user overrode the initial
|
||||||
* per pi's zai catalog); qwen stays specialized for visual GENERATION. */
|
* glm-5.3-flash pick (catalog says it accepts images, but qwen is
|
||||||
pdfReader: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
* proven for exactly this look-at-renders job — visualMaker's daily
|
||||||
|
* workload). */
|
||||||
|
pdfReader: { model: "qwen/qwen3.8-flash", effort: "low" },
|
||||||
/** Extraction is judgment over given evidence — light thinking suffices. */
|
/** Extraction is judgment over given evidence — light thinking suffices. */
|
||||||
extractor: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
extractor: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
||||||
/** The auditor verifies evidence exists; keep it cheap. */
|
/** The auditor verifies evidence exists; keep it cheap. */
|
||||||
|
|||||||
Generated
+278
@@ -40,6 +40,7 @@
|
|||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.1",
|
||||||
"next": "^16.3.2",
|
"next": "^16.3.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdfjs-dist": "^6.3.289",
|
||||||
"puppeteer-core": "^25.8.0",
|
"puppeteer-core": "^25.8.0",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@@ -3531,6 +3532,271 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "1.0.9",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "1.0.9",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "1.0.9",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
|
||||||
@@ -14984,6 +15250,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "6.3.289",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
|
||||||
|
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pe-library": {
|
"node_modules/pe-library": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.1",
|
||||||
"next": "^16.3.2",
|
"next": "^16.3.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdfjs-dist": "^6.3.289",
|
||||||
"puppeteer-core": "^25.8.0",
|
"puppeteer-core": "^25.8.0",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
+40
-4
@@ -42,6 +42,27 @@ export function lhTool<TInput, TResult = unknown>(def: LhTool<TInput, TResult>):
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich tool-result escape hatch (2026-09 sources v0): a tool whose result
|
||||||
|
* includes IMAGES (the read tool reading an image file) returns this shape
|
||||||
|
* instead of a plain value — toPiTool maps it to pi's native tool-result
|
||||||
|
* content parts (TextContent | ImageContent)[] so the vision-capable model
|
||||||
|
* actually SEES the image. `images` carries base64 data + mimeType, exactly
|
||||||
|
* as pi's ImageContent wants. details rides through to the record untouched.
|
||||||
|
*/
|
||||||
|
export interface LhToolImageResult {
|
||||||
|
text: string;
|
||||||
|
images?: { data: string; mimeType: string }[];
|
||||||
|
/** The record-facing result (client rendering / persistence). */
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageResult(v: unknown): v is LhToolImageResult {
|
||||||
|
return (
|
||||||
|
typeof v === "object" && v !== null && "text" in v && typeof (v as LhToolImageResult).text === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** lh's port of the SDK's invalid-input message: what the model retried on. */
|
/** lh's port of the SDK's invalid-input message: what the model retried on. */
|
||||||
export class ToolInputError extends Error {
|
export class ToolInputError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -91,11 +112,26 @@ export function toPiTool(def: LhTool<any, any>): AgentTool<any, any> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const result = await def.execute(parsed.data, { toolCallId });
|
const result = await def.execute(parsed.data, { toolCallId });
|
||||||
|
// Rich image result: model-facing content parts carry the images
|
||||||
|
// (pi's ToolResultMessage.content is (TextContent | ImageContent)[]).
|
||||||
|
if (isImageResult(result)) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: result.text },
|
||||||
|
...(result.images ?? []).map((img) => ({
|
||||||
|
type: "image" as const,
|
||||||
|
data: img.data,
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
details: result.details ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
// The model's view: today's parity — string results pass verbatim,
|
// The model's view: today's parity — string results pass verbatim,
|
||||||
// objects as JSON.
|
// objects as JSON.
|
||||||
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
|
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
|
||||||
details: result,
|
details: result,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ Find out what he actually wants taught. With a subject he doesn't know yet, the
|
|||||||
|
|
||||||
## How your questions arrive
|
## How your questions arrive
|
||||||
|
|
||||||
Your questions are the substance of the session right now: they **open instantly** in the learner's dock, front and center — that immediacy is exactly what probing needs. Ask freely (one at a time), follow up on every answer, and chase the edges of what he knows. The probe is working when the questions are getting sharper, not easier.
|
Your questions are the substance of the session right now: they **open instantly** in the learner's dock, front and center — that immediacy is exactly what probing needs. Ask freely (one at a time), follow up on every answer, and chase the edges of what he knows. The probe is working when the questions are getting sharper, not easier.
|
||||||
|
|
||||||
|
If the learner references a local file (notes, a syllabus, code he's working on), the `read` tool can inspect it directly — ground your assessment in his actual material instead of asking him to paste contents.
|
||||||
@@ -31,3 +31,17 @@ message: say so plainly and continue with what you can establish, flagging the u
|
|||||||
A job can also be **aborted by the learner** — you'll get a one-line "was aborted"
|
A job can also be **aborted by the learner** — you'll get a one-line "was aborted"
|
||||||
message instead of the result. Acknowledge it in one sentence and continue teaching
|
message instead of the result. Acknowledge it in one sentence and continue teaching
|
||||||
without it (offering to re-dispatch is fine if the learner wants the result).
|
without it (offering to re-dispatch is fine if the learner wants the result).
|
||||||
|
|
||||||
|
## Learner source files
|
||||||
|
|
||||||
|
The learner may paste **file paths** into the chat — course PDFs, notes, screenshots, code.
|
||||||
|
Reading them is on your own terms; nothing is injected automatically.
|
||||||
|
|
||||||
|
- For quick looks at plain text or image files, call `read` directly (it returns the text or
|
||||||
|
shows you the image; large files come back in slices with continuation offsets).
|
||||||
|
- For PDFs, dispatch a `pdf` job instead: it runs a background reader (triage, targeted
|
||||||
|
extraction, visual page reading) and returns a **brief with page citations** as a new
|
||||||
|
message — the same fire-and-forget discipline as research above. Give it the path exactly
|
||||||
|
as the learner gave it plus the specific question you need answered.
|
||||||
|
- Ask for the path when the learner references a file they haven't given you — never ask them
|
||||||
|
to paste file contents into the chat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared headless-browser helper for the verifying sub-agents (W6):
|
* Shared headless-browser helper for the verifying sub-agents (W6):
|
||||||
@@ -46,6 +46,16 @@ export interface RenderSvgOptions {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RenderPdfOptions {
|
||||||
|
/** Render DPI — 72 is 100% zoom; the skill's guidance is 100/150/200. */
|
||||||
|
dpi?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedPdfPage {
|
||||||
|
page: number;
|
||||||
|
png: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HeadlessApi {
|
export interface HeadlessApi {
|
||||||
/** True when a Chrome executable is resolvable (cheap, no launch). */
|
/** True when a Chrome executable is resolvable (cheap, no launch). */
|
||||||
available(): boolean;
|
available(): boolean;
|
||||||
@@ -55,6 +65,9 @@ export interface HeadlessApi {
|
|||||||
runDemo(html: string, opts?: RunDemoOptions): Promise<DemoRunResult>;
|
runDemo(html: string, opts?: RunDemoOptions): Promise<DemoRunResult>;
|
||||||
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
|
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
|
||||||
renderSvg(svg: string, opts?: RenderSvgOptions): Promise<Buffer>;
|
renderSvg(svg: string, opts?: RenderSvgOptions): Promise<Buffer>;
|
||||||
|
/** Render PDF pages to PNG buffers via Chrome's pdfium viewer (for the
|
||||||
|
* pdf-reader sub-agent's look-at-the-page loop). */
|
||||||
|
renderPdfPages(pdfPath: string, pages: number[], opts?: RenderPdfOptions): Promise<RenderedPdfPage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam — undefined in production. */
|
/** Test seam — undefined in production. */
|
||||||
@@ -94,7 +107,7 @@ const NO_CHROME_MESSAGE =
|
|||||||
"No Chrome/Chromium found for headless testing. Install Google Chrome or set CHROME_PATH to a Chrome executable. " +
|
"No Chrome/Chromium found for headless testing. Install Google Chrome or set CHROME_PATH to a Chrome executable. " +
|
||||||
"(Verification is mandatory — demos and visuals are never delivered untested.)";
|
"(Verification is mandatory — demos and visuals are never delivered untested.)";
|
||||||
|
|
||||||
async function launchBrowser(): Promise<import("puppeteer-core").Browser> {
|
async function launchBrowser(extraArgs: string[] = []): Promise<import("puppeteer-core").Browser> {
|
||||||
const puppeteer = (await import("puppeteer-core")).default;
|
const puppeteer = (await import("puppeteer-core")).default;
|
||||||
const executablePath = resolveChromeExecutablePath();
|
const executablePath = resolveChromeExecutablePath();
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
@@ -102,7 +115,7 @@ async function launchBrowser(): Promise<import("puppeteer-core").Browser> {
|
|||||||
// Explicit path when we found one; otherwise let puppeteer resolve the
|
// Explicit path when we found one; otherwise let puppeteer resolve the
|
||||||
// system Chrome install itself via the "chrome" channel.
|
// system Chrome install itself via the "chrome" channel.
|
||||||
...(executablePath ? { executablePath } : { channel: "chrome" as const }),
|
...(executablePath ? { executablePath } : { channel: "chrome" as const }),
|
||||||
args: ["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"],
|
args: ["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", ...extraArgs],
|
||||||
});
|
});
|
||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
@@ -275,6 +288,96 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buff
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PDF page rasterization (pdfjs paints, Chrome hosts) ───────────────
|
||||||
|
|
||||||
|
/** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes.
|
||||||
|
*
|
||||||
|
* The engine (user-chosen hybrid, live-verified 2026-09-19): pdfjs paints
|
||||||
|
* each page into a <canvas> (scale = dpi/72, the skill's zoom arithmetic)
|
||||||
|
* inside a headless Chrome tab, and the canvas pixels come back via
|
||||||
|
* toDataURL. Chrome's OWN PDF viewer plugin does not paint in headless
|
||||||
|
* mode (verified: identical blank screenshots regardless of page), so the
|
||||||
|
* canvas shim is the pixel path — Chrome still hosts and encodes, no
|
||||||
|
* native node-canvas anywhere. PDF bytes pass in as base64 (fetch() is
|
||||||
|
* blocked on file://); pdfjs runs via its real worker module under
|
||||||
|
* --allow-file-access-from-files. */
|
||||||
|
async function renderPdfPages(
|
||||||
|
pdfPath: string,
|
||||||
|
pages: number[],
|
||||||
|
opts: RenderPdfOptions = {},
|
||||||
|
): Promise<RenderedPdfPage[]> {
|
||||||
|
const dpi = opts.dpi ?? 150;
|
||||||
|
const pdfAbs = path.resolve(pdfPath);
|
||||||
|
const pdfjsUrl = pathToFileURL(
|
||||||
|
path.join(PATHS_nodeModules(), "pdfjs-dist/legacy/build/pdf.mjs"),
|
||||||
|
).href;
|
||||||
|
const b64 = fs.readFileSync(pdfAbs).toString("base64");
|
||||||
|
|
||||||
|
const dir = tempDir("lh-pdf-");
|
||||||
|
const file = path.join(dir, "shim.html");
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'><style>html,body{margin:0;padding:0;background:#fff}canvas{display:block}</style></head><body></body></html>",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const browser = await launchBrowser(["--allow-file-access-from-files"]);
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 1600, deviceScaleFactor: 1 });
|
||||||
|
await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 });
|
||||||
|
const results = await page.evaluate(
|
||||||
|
async (b64pdf, workerUrl, pageNos, dpi) => {
|
||||||
|
// window-scope dynamic import (Chrome evaluates this string in the
|
||||||
|
// shim page, not under Node)
|
||||||
|
const pdfjs = (await import(workerUrl)) as typeof import("pdfjs-dist");
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl.replace("pdf.mjs", "pdf.worker.mjs");
|
||||||
|
const bin = atob(b64pdf);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
const doc = await pdfjs.getDocument({ data: bytes }).promise;
|
||||||
|
const out: Array<{ page: number; dataUrl: string; w: number; h: number }> = [];
|
||||||
|
for (const pageNo of pageNos) {
|
||||||
|
if (pageNo < 1 || pageNo > doc.numPages) continue;
|
||||||
|
const pg = await doc.getPage(pageNo);
|
||||||
|
const vp = pg.getViewport({ scale: dpi / 72 });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.ceil(vp.width);
|
||||||
|
canvas.height = Math.ceil(vp.height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("canvas 2d context unavailable");
|
||||||
|
await pg.render({ canvasContext: ctx, canvas, viewport: vp }).promise;
|
||||||
|
out.push({ page: pageNo, dataUrl: canvas.toDataURL("image/png"), w: canvas.width, h: canvas.height });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
b64,
|
||||||
|
pdfjsUrl,
|
||||||
|
pages,
|
||||||
|
dpi,
|
||||||
|
);
|
||||||
|
return results.map((r) => ({
|
||||||
|
page: r.page,
|
||||||
|
// canvas-encoded PNG → providerSafePng for the same single-IDAT
|
||||||
|
// canonical form every other vision-model image gets.
|
||||||
|
png: providerSafePng(Buffer.from(r.dataUrl.slice(r.dataUrl.indexOf(",") + 1), "base64")),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort cleanup */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** node_modules — resolved from this module's location. */
|
||||||
|
function PATHS_nodeModules(): string {
|
||||||
|
// import.meta.url = .../src/agent/subagents/headless.ts → three up + node_modules
|
||||||
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "node_modules");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── the real API ────────────────────────────────────────────────────────────
|
// ─── the real API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const realHeadless: HeadlessApi = {
|
export const realHeadless: HeadlessApi = {
|
||||||
@@ -286,4 +389,5 @@ export const realHeadless: HeadlessApi = {
|
|||||||
},
|
},
|
||||||
runDemo,
|
runDemo,
|
||||||
renderSvg,
|
renderSvg,
|
||||||
|
renderPdfPages,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF plumbing for the pdf-reader sub-agent — sources v0 (2026-09): Node
|
||||||
|
* ports of the four pdf-reader skill scripts (pdf_info / pdf_extract /
|
||||||
|
* pdf_search / pdf_render), on the user-chosen hybrid stack:
|
||||||
|
*
|
||||||
|
* - TEXT (info / extract / search): pdfjs-dist — pure JS, no native deps.
|
||||||
|
* - RENDER: the shared headless-Chrome infra (subagents/headless.ts,
|
||||||
|
* puppeteer + system Chrome) screenshots pdfium-rendered pages.
|
||||||
|
*
|
||||||
|
* Doctrine (the skill's reading strategy, mirrored in the sub-agent's system
|
||||||
|
* prompt): triage with pdf_info FIRST, then size-based strategy — short PDFs
|
||||||
|
* render all pages, medium render flagged (math/image-heavy) pages, long
|
||||||
|
* ones search for targeted lookups before reading anything at length.
|
||||||
|
*
|
||||||
|
* These are pure library functions — no model in sight. The pdf-reader
|
||||||
|
* agent (pdf-reader.ts) drives them through its own tool loop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PdfPageInfo {
|
||||||
|
page: number;
|
||||||
|
text_length: number;
|
||||||
|
image_count: number;
|
||||||
|
/** Fraction of math-flavored chars — the render-this-page signal (port
|
||||||
|
* of the skill's unicode-range density heuristic). */
|
||||||
|
math_density: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfInfo {
|
||||||
|
path: string;
|
||||||
|
page_count: number;
|
||||||
|
/** Mean chars/page — the size class the strategy branches on. */
|
||||||
|
mean_text_length: number;
|
||||||
|
pages: PdfPageInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the legacy pdfjs build (runs headless in plain node, no worker). */
|
||||||
|
async function loadPdfjs() {
|
||||||
|
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||||
|
return pdfjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDocument(filePath: string) {
|
||||||
|
const pdfjs = await loadPdfjs();
|
||||||
|
const data = new Uint8Array(await fs.readFile(filePath));
|
||||||
|
// The legacy build in plain node runs on a main-thread fake worker (no
|
||||||
|
// workerSrc needed); teardown is the loading task's destroy().
|
||||||
|
const task = pdfjs.getDocument({ data });
|
||||||
|
return { doc: await task.promise, task };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concatenate a page's text items into a body string. */
|
||||||
|
function itemsToText(items: unknown[]): string {
|
||||||
|
let out = "";
|
||||||
|
for (const it of items) {
|
||||||
|
if (it && typeof it === "object" && "str" in (it as Record<string, unknown>)) {
|
||||||
|
const str = (it as { str?: unknown }).str;
|
||||||
|
if (typeof str === "string") out += str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Math-flavored char fraction — the skill's heuristic, verbatim ranges. */
|
||||||
|
function mathDensity(text: string): number {
|
||||||
|
let mathChars = 0;
|
||||||
|
for (const c of text) {
|
||||||
|
const code = c.codePointAt(0)!;
|
||||||
|
if (
|
||||||
|
(code >= 0x2200 && code <= 0x22ff) || // Mathematical Operators
|
||||||
|
(code >= 0x2100 && code <= 0x214f) || // Letterlike Symbols
|
||||||
|
(code >= 0x2190 && code <= 0x21ff) || // Arrows
|
||||||
|
(code >= 0x27c0 && code <= 0x27ef) || // Misc Mathematical Symbols-A
|
||||||
|
(code >= 0x2980 && code <= 0x29ff) || // Misc Mathematical Symbols-B
|
||||||
|
(code >= 0x2a00 && code <= 0x2aff) || // Supplemental Mathematical Operators
|
||||||
|
(code >= 0x0370 && code <= 0x03ff) || // Greek
|
||||||
|
(code >= 0x1d400 && code <= 0x1d7ff) || // Mathematical Alphanumeric Symbols
|
||||||
|
"∫∑∏√∂∇∞≈≠≤≥±×÷∈∉⊂⊃∪∩∧∨¬∀∃∅".includes(c)
|
||||||
|
) {
|
||||||
|
mathChars++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mathChars / Math.max(text.length, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap validity probe: getDocument resolves only for parseable PDFs —
|
||||||
|
* no page walk, no text extraction (unlike pdfInfo). The reader's upfront
|
||||||
|
* unreadable-source gate; garbage files throw pdfjs's precise error. */
|
||||||
|
export async function pdfProbe(filePath: string): Promise<void> {
|
||||||
|
const { task } = await openDocument(filePath);
|
||||||
|
await task.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pdf_info: page count + per-page triage signals. ALWAYS run first. */
|
||||||
|
export async function pdfInfo(filePath: string): Promise<PdfInfo> {
|
||||||
|
const pdfjs = await loadPdfjs(); // OPS constant for the operator-list scan
|
||||||
|
const { doc } = await openDocument(filePath);
|
||||||
|
const pages: PdfPageInfo[] = [];
|
||||||
|
let totalText = 0;
|
||||||
|
for (let i = 1; i <= doc.numPages; i++) {
|
||||||
|
const page = await doc.getPage(i);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
const text = itemsToText(content.items as unknown[]);
|
||||||
|
// count page images: paintImageXObject ops in the operator list
|
||||||
|
const ops = await page.getOperatorList();
|
||||||
|
let imageCount = 0;
|
||||||
|
for (const fn of ops.fnArray) {
|
||||||
|
if (fn === pdfjs.OPS.paintImageXObject) imageCount++;
|
||||||
|
}
|
||||||
|
totalText += text.length;
|
||||||
|
pages.push({
|
||||||
|
page: i,
|
||||||
|
text_length: text.length,
|
||||||
|
image_count: imageCount,
|
||||||
|
math_density: Math.round(mathDensity(text) * 10000) / 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
page_count: doc.numPages,
|
||||||
|
mean_text_length: Math.round(totalText / Math.max(doc.numPages, 1)),
|
||||||
|
pages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse 'all' | '1-5' | '1,3,7' | '3' into a sorted 1-indexed page list. */
|
||||||
|
export function parsePageSpec(spec: string, total: number): number[] {
|
||||||
|
const trimmed = spec.trim().toLowerCase();
|
||||||
|
if (trimmed === "all") return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
const pages = new Set<number>();
|
||||||
|
for (const part of spec.split(",")) {
|
||||||
|
const p = part.trim();
|
||||||
|
if (!p) continue;
|
||||||
|
if (p.includes("-")) {
|
||||||
|
const [startRaw, endRaw] = p.split("-", 2) as [string, string];
|
||||||
|
const start = Math.max(1, parseInt(startRaw, 10));
|
||||||
|
const end = Math.min(total, parseInt(endRaw, 10));
|
||||||
|
for (let pg = start; pg <= end; pg++) pages.add(pg);
|
||||||
|
} else {
|
||||||
|
const pg = parseInt(p, 10);
|
||||||
|
if (pg >= 1 && pg <= total) pages.add(pg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...pages].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pdf_extract: text of the requested pages, `--- Page N ---` headed. */
|
||||||
|
export async function pdfExtract(filePath: string, pageSpec: string): Promise<string> {
|
||||||
|
const { doc } = await openDocument(filePath);
|
||||||
|
const pages = parsePageSpec(pageSpec, doc.numPages);
|
||||||
|
if (pages.length === 0) return "No pages selected — page spec matched nothing.";
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const i of pages) {
|
||||||
|
const page = await doc.getPage(i);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
out.push(`--- Page ${i} ---`);
|
||||||
|
out.push(itemsToText(content.items as unknown[]));
|
||||||
|
}
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfSearchHit {
|
||||||
|
page: number;
|
||||||
|
line: number;
|
||||||
|
/** contextLines around the hit, `>>>` marking the hit line. */
|
||||||
|
context: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pdf_search: keyword/regex across all pages with context lines.
|
||||||
|
* pageTexts rides along so the agent can extract a hit's full page without
|
||||||
|
* a second pass (the skill pipelines `search → extract` the same way). */
|
||||||
|
export async function pdfSearch(
|
||||||
|
filePath: string,
|
||||||
|
query: string,
|
||||||
|
opts?: { contextLines?: number; literal?: boolean },
|
||||||
|
): Promise<{ hits: PdfSearchHit[]; pageTexts: Map<number, string> }> {
|
||||||
|
const contextLines = opts?.contextLines ?? 3;
|
||||||
|
const { doc } = await openDocument(filePath);
|
||||||
|
let pattern: RegExp;
|
||||||
|
if (opts?.literal) {
|
||||||
|
pattern = new RegExp(escapeRegex(query), "i");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
pattern = new RegExp(query, "i");
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Invalid regex: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hits: PdfSearchHit[] = [];
|
||||||
|
const pageTexts = new Map<number, string>();
|
||||||
|
for (let i = 1; i <= doc.numPages; i++) {
|
||||||
|
const page = await doc.getPage(i);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
const text = itemsToText(content.items as unknown[]);
|
||||||
|
pageTexts.set(i, text);
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (let ln = 0; ln < lines.length; ln++) {
|
||||||
|
if (!pattern.test(lines[ln]!)) continue;
|
||||||
|
const start = Math.max(0, ln - contextLines);
|
||||||
|
const end = Math.min(lines.length, ln + contextLines + 1);
|
||||||
|
const context = lines
|
||||||
|
.slice(start, end)
|
||||||
|
.map((l, j) => `${start + j === ln ? ">>>" : " "} ${l}`)
|
||||||
|
.join("\n");
|
||||||
|
hits.push({ page: i, line: ln + 1, context });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hits, pageTexts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where rendered pages land (mirrors the skill's /tmp convention). */
|
||||||
|
export function renderDir(filePath: string): string {
|
||||||
|
// FNV-1a short hash of the absolute path — stable per file, no deps.
|
||||||
|
const abs = path.resolve(filePath);
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < abs.length; i++) {
|
||||||
|
h ^= abs.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return path.join("/tmp", `lh-pdf-${h.toString(16).padStart(8, "0")}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
|
||||||
|
import { piEngineFor, thinkingLevelFor, type PiEngine } from "../models.ts";
|
||||||
|
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
||||||
|
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
||||||
|
import { pdfExtract, pdfInfo, pdfProbe, pdfSearch, type PdfSearchHit } from "./pdf-ops.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pdf-reader sub-agent (sources v0, 2026-09): reads a learner-provided
|
||||||
|
* PDF on the teacher's behalf — a hand-rolled loop like the visual-maker's
|
||||||
|
* (image-in-the-loop can't ride the execute-tool plumbing: rendered pages
|
||||||
|
* are shown to THIS agent's eyes, never delivered onward — user-decided).
|
||||||
|
*
|
||||||
|
* System prompt = the pdf-reader skill's reading doctrine, verbatim in
|
||||||
|
* strategy: triage with pdf_info FIRST, then size-based strategy — short
|
||||||
|
* PDFs render all pages, medium render flagged (math/image-heavy) pages,
|
||||||
|
* long ones search for targeted lookups before reading anything at length.
|
||||||
|
*
|
||||||
|
* Its final text IS the brief (researcher idiom), with (p. N) citations;
|
||||||
|
* runPdfJob wraps it into PdfResult with pagesConsulted tracked by the loop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INSTRUCTIONS = `You are a PDF reading specialist. You work for a teacher agent inside a learning
|
||||||
|
application: given a PDF path and a question, read the document on the teacher's behalf and
|
||||||
|
produce a focused brief that the teacher can teach from, with page citations.
|
||||||
|
|
||||||
|
You operate in an isolated context. The task gives you the PDF path and the question — that is
|
||||||
|
everything you know.
|
||||||
|
|
||||||
|
Reading doctrine — always in this order:
|
||||||
|
1. TRIAGE FIRST: call pdf_info. It returns page count plus per-page signals (text_length,
|
||||||
|
image_count, math_density). Never read or render anything before this.
|
||||||
|
2. Pick a strategy by document size:
|
||||||
|
- SHORT (≤ 10 pages): extract the full text; render every page that has meaningful
|
||||||
|
math_density or images (you read equations and diagrams visually, not as extracted text).
|
||||||
|
- MEDIUM (11-40 pages): extract promising pages; render only flagged pages (math- or
|
||||||
|
image-heavy, or where extraction looks thin).
|
||||||
|
- LONG (> 40 pages): pdf_search for the question's key terms first; extract and render only
|
||||||
|
the pages that hit. Never read a long document front-to-back.
|
||||||
|
3. When the extracted text of a page looks math- or diagram-heavy, or garbled, RENDER that
|
||||||
|
page and read it visually before drawing conclusions from it.
|
||||||
|
4. Render DPI guidance: 100 for text-dominant pages, 150 the default, 200 for dense small
|
||||||
|
print (math details, fine diagrams).
|
||||||
|
|
||||||
|
Tool outputs may be error strings (file unreadable, invalid regex, no hits). Adapt: narrow the
|
||||||
|
page spec, change the query, lower DPI. If the document genuinely cannot answer the question,
|
||||||
|
say so in the brief instead of inventing content.
|
||||||
|
|
||||||
|
Your FINAL assistant message is your entire deliverable — it must stand alone, in this format:
|
||||||
|
2-6 sentences (or short sections) answering the teacher's question from the document, each
|
||||||
|
claim cited as (p. N). End with a line "Pages consulted: N, N, …" listing every page you
|
||||||
|
extracted or rendered.`;
|
||||||
|
|
||||||
|
const MAX_ITERATIONS = 14;
|
||||||
|
/** Rendered pages per pdf_render call — the skill renders selectively; a
|
||||||
|
* batch cap keeps one over-eager call from flooding the vision context. */
|
||||||
|
const RENDER_BATCH_CAP = 6;
|
||||||
|
/** Extract output ceiling — narrow the spec instead of dumping a book. */
|
||||||
|
const EXTRACT_CAP = 24_000;
|
||||||
|
|
||||||
|
export interface PdfReaderOptions {
|
||||||
|
engine?: PiEngine;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
onUsage?: (usage: Usage) => void;
|
||||||
|
onActivity?: (line: string) => void;
|
||||||
|
headless?: HeadlessApi;
|
||||||
|
maxIterations?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfReaderInput extends PdfToolInput {}
|
||||||
|
|
||||||
|
export function createPdfTools(
|
||||||
|
pdfPath: string,
|
||||||
|
): {
|
||||||
|
pdf_info: LhTool<any, any>;
|
||||||
|
pdf_extract: LhTool<any, any>;
|
||||||
|
pdf_render: LhTool<any, any>;
|
||||||
|
pdf_search: LhTool<any, any>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
pdf_info: lhTool({
|
||||||
|
name: "pdf_info",
|
||||||
|
description: "Metadata + per-page triage signals for the PDF (page count, text length, image count, math density). ALWAYS call this first.",
|
||||||
|
schema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const info = await pdfInfo(pdfPath);
|
||||||
|
const lines = [
|
||||||
|
`File: ${info.path}`,
|
||||||
|
`Pages: ${info.page_count} (mean text length/page: ${info.mean_text_length})`,
|
||||||
|
...info.pages.map(
|
||||||
|
(p) =>
|
||||||
|
`p${p.page}: len=${p.text_length} img=${p.image_count} math=${p.math_density.toFixed(2)}`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return lines.join("\n");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pdf_extract: lhTool({
|
||||||
|
name: "pdf_extract",
|
||||||
|
description: "Extract text from pages (spec: 'all', '1-5', '1,3,7', '3'). Use pdf_info first; narrow the spec for big documents.",
|
||||||
|
schema: z.object({ pages: z.string().describe("Page spec, e.g. 'all', '1-5', '1,3,7'") }),
|
||||||
|
execute: async ({ pages }) => {
|
||||||
|
const text = await pdfExtract(pdfPath, pages);
|
||||||
|
if (text.length > EXTRACT_CAP) {
|
||||||
|
return `${text.slice(0, EXTRACT_CAP)}\n\n[Output truncated at ${EXTRACT_CAP} chars — narrow the page spec and extract in smaller batches.]`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pdf_render: lhTool({
|
||||||
|
name: "pdf_render",
|
||||||
|
description:
|
||||||
|
"Render pages to images and show them to you for visual reading (equations, diagrams, layout). DPI: 100 text, 150 default, 200 dense small print. Max 6 pages per call.",
|
||||||
|
schema: z.object({
|
||||||
|
pages: z.string().describe("Page spec, e.g. '1-3' or '2,5,8'"),
|
||||||
|
dpi: z.number().int().min(72).max(300).default(150).describe("Render DPI (100 text / 150 default / 200 dense)"),
|
||||||
|
}),
|
||||||
|
execute: async () => {
|
||||||
|
// Actual rendering happens OUTSIDE execute — the loop appends the
|
||||||
|
// images as a user message after the tool results (the
|
||||||
|
// image-in-the-loop idiom). execute returns the plan the loop uses.
|
||||||
|
return "render requested";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pdf_search: lhTool({
|
||||||
|
name: "pdf_search",
|
||||||
|
description: "Search the PDF's text by keyword or regex (case-insensitive); returns hits with context lines. Use for long documents before reading anything at length.",
|
||||||
|
schema: z.object({
|
||||||
|
query: z.string().describe("Keyword or regex"),
|
||||||
|
context_lines: z.number().int().min(0).max(8).default(3).describe("Context lines around each hit"),
|
||||||
|
literal: z.boolean().default(false).describe("true = treat the query literally (no regex)"),
|
||||||
|
}),
|
||||||
|
execute: async ({ query, context_lines, literal }) => {
|
||||||
|
const { hits } = await pdfSearch(pdfPath, query, { contextLines: context_lines, literal });
|
||||||
|
if (hits.length === 0) return "No hits.";
|
||||||
|
const cap = hits.slice(0, 40);
|
||||||
|
const body = cap.map((h: PdfSearchHit) => `=== Page ${h.page}, line ${h.line} ===\n${h.context}`).join("\n");
|
||||||
|
const more = hits.length > cap.length ? `\n\n[${hits.length - cap.length} more hits — narrow the query.]` : "";
|
||||||
|
return body + more;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolText(toolCallId: string, toolName: string, value: string): ToolResultMessage {
|
||||||
|
return {
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
content: [{ type: "text", text: value }],
|
||||||
|
isError: value.startsWith("ERROR:"),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Doctrine gate (the visual-maker's unlooked-finish rule, ported): never
|
||||||
|
* deliver a brief that consulted nothing — an unread PDF brief is made-up
|
||||||
|
* content, exactly the class of failure the gate exists for. */
|
||||||
|
const UNREAD_FINISH_MESSAGE =
|
||||||
|
"You finished without consulting any page of the PDF — your brief would be invention, not reading. Extract or render the relevant pages first, then write the brief from what you actually saw.";
|
||||||
|
|
||||||
|
export async function runPdfReaderAgent(
|
||||||
|
input: PdfReaderInput,
|
||||||
|
opts: PdfReaderOptions = {},
|
||||||
|
): Promise<PdfResult> {
|
||||||
|
const headless = opts.headless ?? getHeadless();
|
||||||
|
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
||||||
|
const pdfPath = input.path;
|
||||||
|
|
||||||
|
// Deterministic upfront failure: unreadable / unparseable / no Chrome
|
||||||
|
// for renders — the brief never starts on a broken source.
|
||||||
|
const openError = await probeReadable(pdfPath);
|
||||||
|
if (openError) {
|
||||||
|
return { path: pdfPath, question: input.question, brief: `PDF failed: ${openError}`, pagesConsulted: [] };
|
||||||
|
}
|
||||||
|
const chromeReason = headless.unavailableReason();
|
||||||
|
// Chrome missing does NOT fail the run: text-side tools still work; the
|
||||||
|
// doctrine degrades to extract+search (render calls return an error note).
|
||||||
|
|
||||||
|
const tools = createPdfTools(pdfPath);
|
||||||
|
const toolDeclarations = piToolDeclarations(tools);
|
||||||
|
const engine = opts.engine ?? piEngineFor("pdfReader");
|
||||||
|
const reasoning = thinkingLevelFor("pdfReader");
|
||||||
|
const prompt = [
|
||||||
|
`PDF path: ${pdfPath}`,
|
||||||
|
`Question (what the teacher needs from this document): ${input.question || "(no question — give a structural overview)"}`,
|
||||||
|
"",
|
||||||
|
"Read the document per your doctrine, then produce your final brief in the required format.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
let messages: Message[] = [{ role: "user", content: prompt, timestamp: Date.now() }];
|
||||||
|
/** Every page the reader extracted or rendered (the citation set). */
|
||||||
|
const consulted = new Set<number>();
|
||||||
|
let finalText = "";
|
||||||
|
let nudged = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||||
|
const result = await streamOnce(engine, INSTRUCTIONS, messages, toolDeclarations, reasoning, opts.abortSignal);
|
||||||
|
// Money channel: each iteration is one model call.
|
||||||
|
opts.onUsage?.(result.usage);
|
||||||
|
messages = [...messages, result];
|
||||||
|
|
||||||
|
const toolCalls = result.content.filter(
|
||||||
|
(c): c is Extract<typeof c, { type: "toolCall" }> => c.type === "toolCall",
|
||||||
|
);
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
finalText = result.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
if (consulted.size === 0 && !nudged) {
|
||||||
|
nudged = true;
|
||||||
|
messages = [
|
||||||
|
...messages,
|
||||||
|
{ role: "user", content: UNREAD_FINISH_MESSAGE, timestamp: Date.now() },
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { path: pdfPath, question: input.question, brief: finalText || "(no brief text)", pagesConsulted: [...consulted].sort((a, b) => a - b) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResults: ToolResultMessage[] = [];
|
||||||
|
/** Rendered pages this batch, appended as images AFTER the tool
|
||||||
|
* results — the visual-maker's image-in-the-loop idiom. */
|
||||||
|
let renderedImages: { page: number; png: Buffer }[] | null = null;
|
||||||
|
|
||||||
|
for (const call of toolCalls) {
|
||||||
|
const args = (call.arguments ?? {}) as Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
if (call.name === "pdf_info") {
|
||||||
|
opts.onActivity?.("pdf_info: triaging");
|
||||||
|
const r = await tools.pdf_info.execute!({}, { toolCallId: call.id });
|
||||||
|
toolResults.push(toolText(call.id, call.name, String(r)));
|
||||||
|
} else if (call.name === "pdf_extract") {
|
||||||
|
const spec = String(args.pages ?? "all");
|
||||||
|
opts.onActivity?.(`pdf_extract: ${spec}`);
|
||||||
|
const r = await tools.pdf_extract.execute!({ pages: spec }, { toolCallId: call.id });
|
||||||
|
toolResults.push(toolText(call.id, call.name, String(r)));
|
||||||
|
for (const p of specNumbers(spec, Infinity)) consulted.add(p);
|
||||||
|
} else if (call.name === "pdf_search") {
|
||||||
|
const query = String(args.query ?? "");
|
||||||
|
const contextLines = numOr(args.context_lines, 3);
|
||||||
|
const literal = Boolean(args.literal);
|
||||||
|
opts.onActivity?.(`pdf_search: "${query}"`);
|
||||||
|
// Direct call (not the tool's execute): the loop needs the hits'
|
||||||
|
// pages — a hit's context lines ARE consultation, so they join
|
||||||
|
// the citation set (the unread-finish gate counts them).
|
||||||
|
const { hits } = await pdfSearch(pdfPath, query, { contextLines, literal });
|
||||||
|
for (const h of hits) consulted.add(h.page);
|
||||||
|
const cap = hits.slice(0, 40);
|
||||||
|
const body =
|
||||||
|
cap.length === 0
|
||||||
|
? "No hits."
|
||||||
|
: cap.map((h) => `=== Page ${h.page}, line ${h.line} ===\n${h.context}`).join("\n") +
|
||||||
|
(hits.length > cap.length ? `\n\n[${hits.length - cap.length} more hits — narrow the query.]` : "");
|
||||||
|
toolResults.push(toolText(call.id, call.name, body));
|
||||||
|
} else if (call.name === "pdf_render") {
|
||||||
|
const spec = String(args.pages ?? "1");
|
||||||
|
const dpi = numOr(args.dpi, 150);
|
||||||
|
if (chromeReason) {
|
||||||
|
toolResults.push(
|
||||||
|
toolText(call.id, call.name, `ERROR: rendering unavailable — ${chromeReason} Read via pdf_extract instead.`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const wanted = specNumbers(spec, RENDER_BATCH_CAP);
|
||||||
|
opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`);
|
||||||
|
const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi });
|
||||||
|
for (const p of pages) consulted.add(p.page);
|
||||||
|
if (pages.length === 0) {
|
||||||
|
toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)"));
|
||||||
|
} else {
|
||||||
|
renderedImages = pages;
|
||||||
|
toolResults.push(
|
||||||
|
toolText(
|
||||||
|
call.id,
|
||||||
|
call.name,
|
||||||
|
`Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toolResults.push(toolText(call.id, call.name, `ERROR: unknown tool "${call.name}"`));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toolResults.push(toolText(call.id, call.name, `ERROR: ${msg}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = [...messages, ...toolResults];
|
||||||
|
if (renderedImages) {
|
||||||
|
messages = [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `The rendered page(s), in order (${renderedImages.map((p) => `p${p.page}`).join(", ")}):`,
|
||||||
|
},
|
||||||
|
...renderedImages.map((p) => ({
|
||||||
|
type: "image" as const,
|
||||||
|
data: p.png.toString("base64"),
|
||||||
|
mimeType: "image/png",
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { path: pdfPath, question: input.question, brief: `PDF failed: the reader crashed — ${msg}`, pagesConsulted: [...consulted].sort((a, b) => a - b) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iteration limit: deliver whatever was honestly consulted (never invention).
|
||||||
|
return {
|
||||||
|
path: pdfPath,
|
||||||
|
question: input.question,
|
||||||
|
brief: finalText || `PDF failed: iteration limit (${maxIterations}) reached without a brief.`,
|
||||||
|
pagesConsulted: [...consulted].sort((a, b) => a - b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── small helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Parse a page spec into concrete page numbers, capped. */
|
||||||
|
function specNumbers(spec: string, cap: number): number[] {
|
||||||
|
// total = Infinity: bounds come from the document at execution time; the
|
||||||
|
// cap alone protects the batch.
|
||||||
|
const parts = spec.trim().toLowerCase() === "all" ? [`${cap}`] : spec.split(",");
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const part of parts) {
|
||||||
|
const p = part.trim();
|
||||||
|
if (!p) continue;
|
||||||
|
if (p.includes("-")) {
|
||||||
|
const [s, e] = p.split("-", 2) as [string, string];
|
||||||
|
const start = Math.max(1, parseInt(s, 10) || 1);
|
||||||
|
const end = Math.min(parseInt(e, 10) || start, start + cap - 1);
|
||||||
|
for (let pg = start; pg <= end && out.size < cap; pg++) out.add(pg);
|
||||||
|
} else {
|
||||||
|
const pg = parseInt(p, 10);
|
||||||
|
if (Number.isFinite(pg) && pg >= 1 && out.size < cap) out.add(pg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...out].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numOr(v: unknown, dflt: number): number {
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? v : dflt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap upfront readability probe — real parsing happens inside the tools. */
|
||||||
|
async function probeReadable(pdfPath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const fs = await import("node:fs/promises");
|
||||||
|
const stat = await fs.stat(pdfPath);
|
||||||
|
if (!stat.isFile()) return "not a regular file";
|
||||||
|
if (stat.size === 0) return "file is empty";
|
||||||
|
// parse probe: pdfProbe throws pdfjs's precise error for garbage files
|
||||||
|
await pdfProbe(pdfPath);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One raw model call (no agent loop): the visual-maker's streamOnce,
|
||||||
|
* duplicated here because its module keeps it private. */
|
||||||
|
async function streamOnce(
|
||||||
|
engine: PiEngine,
|
||||||
|
systemPrompt: string,
|
||||||
|
messages: Message[],
|
||||||
|
tools: ReturnType<typeof piToolDeclarations>,
|
||||||
|
reasoning: ReturnType<typeof thinkingLevelFor>,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<AssistantMessage> {
|
||||||
|
const stream = engine.streamFn(engine.model, { systemPrompt, messages, tools }, {
|
||||||
|
reasoning: reasoning === "off" ? undefined : reasoning,
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY || undefined,
|
||||||
|
signal: abortSignal,
|
||||||
|
} as never);
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === "done") return event.message;
|
||||||
|
if (event.type === "error") {
|
||||||
|
if (event.reason === "aborted") {
|
||||||
|
const err = new Error("aborted");
|
||||||
|
const abortedErr = err as Error & { name: string };
|
||||||
|
abortedErr.name = "AbortError";
|
||||||
|
throw abortedErr;
|
||||||
|
}
|
||||||
|
throw new Error(event.error.errorMessage ?? "the model call failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("the model stream ended without a result");
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
type JobKind,
|
type JobKind,
|
||||||
type JobRecord,
|
type JobRecord,
|
||||||
type JobUsage,
|
type JobUsage,
|
||||||
|
type PdfResult,
|
||||||
|
type PdfToolInput,
|
||||||
type ResearchResult,
|
type ResearchResult,
|
||||||
type ResearchToolInput,
|
type ResearchToolInput,
|
||||||
type SubagentSteerMessage,
|
type SubagentSteerMessage,
|
||||||
@@ -20,6 +22,7 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
|||||||
import { MODELS } from "../../../config.ts";
|
import { MODELS } from "../../../config.ts";
|
||||||
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
||||||
import { runDemoBuilder } from "./demo-builder.ts";
|
import { runDemoBuilder } from "./demo-builder.ts";
|
||||||
|
import { runPdfReaderAgent } from "./pdf-reader.ts";
|
||||||
import { runResearcherAgent } from "./researcher.ts";
|
import { runResearcherAgent } from "./researcher.ts";
|
||||||
import { runVisualMaker } from "./visual-maker.ts";
|
import { runVisualMaker } from "./visual-maker.ts";
|
||||||
|
|
||||||
@@ -51,9 +54,10 @@ import { runVisualMaker } from "./visual-maker.ts";
|
|||||||
export const researchModelOverride: { current?: PiEngine } = {};
|
export const researchModelOverride: { current?: PiEngine } = {};
|
||||||
export const demoModelOverride: { current?: PiEngine } = {};
|
export const demoModelOverride: { current?: PiEngine } = {};
|
||||||
export const visualModelOverride: { current?: PiEngine } = {};
|
export const visualModelOverride: { current?: PiEngine } = {};
|
||||||
|
export const pdfModelOverride: { current?: PiEngine } = {};
|
||||||
|
|
||||||
/** The dispatch-time tool input (from src/agent/tools/jobs.ts schemas). */
|
/** The dispatch-time tool input (from src/agent/tools/jobs.ts schemas). */
|
||||||
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput;
|
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput | PdfToolInput;
|
||||||
|
|
||||||
export interface JobRunnerDeps {
|
export interface JobRunnerDeps {
|
||||||
chatStore: ChatStore;
|
chatStore: ChatStore;
|
||||||
@@ -71,7 +75,7 @@ export interface JobRunner {
|
|||||||
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
||||||
* before the tool part is persisted — only crash-recovery re-spawns
|
* before the tool part is persisted — only crash-recovery re-spawns
|
||||||
* resolve the input from the transcript). */
|
* resolve the input from the transcript). */
|
||||||
runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>;
|
runJob(jobId: string, input?: JobToolInput): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One job loop's injected machinery: the kill switch (real abort, 2026-09
|
/** One job loop's injected machinery: the kill switch (real abort, 2026-09
|
||||||
@@ -96,6 +100,7 @@ const KIND_MODEL: Record<JobKind, string> = {
|
|||||||
research: MODELS.researcher.model,
|
research: MODELS.researcher.model,
|
||||||
demo: MODELS.demoBuilder.model,
|
demo: MODELS.demoBuilder.model,
|
||||||
visual: MODELS.visualMaker.model,
|
visual: MODELS.visualMaker.model,
|
||||||
|
pdf: MODELS.pdfReader.model,
|
||||||
"graph-context": "",
|
"graph-context": "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,6 +150,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
|
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
|
||||||
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.current, input, loop),
|
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.current, input, loop),
|
||||||
visual: (jobId, input, loop) => runVisualJob(jobId, visualModelOverride.current, input, loop),
|
visual: (jobId, input, loop) => runVisualJob(jobId, visualModelOverride.current, input, loop),
|
||||||
|
pdf: (jobId, input, loop) => runPdfJob(jobId, pdfModelOverride.current, input, loop),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
|
async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
|
||||||
@@ -185,15 +191,11 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
function resolveInput(
|
function resolveInput(
|
||||||
job: JobRecord,
|
job: JobRecord,
|
||||||
input: JobToolInput | undefined,
|
input: JobToolInput | undefined,
|
||||||
): ResearchToolInput | DemoToolInput | VisualToolInput | undefined {
|
): JobToolInput | undefined {
|
||||||
if (input) return input;
|
if (input) return input;
|
||||||
const chat = chatStore.getChat(job.chatId);
|
const chat = chatStore.getChat(job.chatId);
|
||||||
if (!chat) return undefined;
|
if (!chat) return undefined;
|
||||||
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as
|
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as JobToolInput | undefined;
|
||||||
| ResearchToolInput
|
|
||||||
| DemoToolInput
|
|
||||||
| VisualToolInput
|
|
||||||
| undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── shared plumbing ────────────────────────────────────────────────────
|
// ─── shared plumbing ────────────────────────────────────────────────────
|
||||||
@@ -213,7 +215,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
* actually resumed — until then the job reads "running" so the client's
|
* actually resumed — until then the job reads "running" so the client's
|
||||||
* activity card keeps polling and re-syncs once, at the end.
|
* activity card keeps polling and re-syncs once, at the end.
|
||||||
*/
|
*/
|
||||||
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> {
|
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
|
||||||
const job = jobStore.getJob(jobId);
|
const job = jobStore.getJob(jobId);
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
// Aborted mid-run or mid-delivery (abortJob flips the record before
|
// Aborted mid-run or mid-delivery (abortJob flips the record before
|
||||||
@@ -372,6 +374,44 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── pdf reading (sources v0) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runPdfJob(
|
||||||
|
jobId: string,
|
||||||
|
model: PiEngine | undefined,
|
||||||
|
passedInput: JobToolInput | undefined,
|
||||||
|
loop: LoopContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const ctx = jobContext(jobId);
|
||||||
|
if (!ctx) return;
|
||||||
|
const { job } = ctx;
|
||||||
|
|
||||||
|
const input = (passedInput ?? resolveInput(job, passedInput)) as PdfToolInput | undefined;
|
||||||
|
const path = typeof input?.path === "string" && input.path.trim() ? input.path.trim() : "";
|
||||||
|
const question = typeof input?.question === "string" ? input.question : "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
jobStore.updateJob(jobId, { activity: "reading pdf" });
|
||||||
|
const result = await runPdfReaderAgent(
|
||||||
|
{ path, question },
|
||||||
|
{
|
||||||
|
engine: model,
|
||||||
|
abortSignal: loop.abortSignal,
|
||||||
|
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
|
||||||
|
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (isPdfFailure(result) && stillRunning(jobId))
|
||||||
|
jobStore.updateJob(jobId, { error: result.brief.replace(/^PDF failed:\s*/i, "") });
|
||||||
|
await finishJob(jobId, result);
|
||||||
|
} catch (err) {
|
||||||
|
if (loop.abortSignal.aborted) return;
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||||
|
await finishJob(jobId, { path, question, brief: `PDF failed: ${reason}`, pagesConsulted: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { runJob };
|
return { runJob };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +426,11 @@ function isVisualFailure(result: VisualResult): boolean {
|
|||||||
return typeof result.svg !== "string" || result.svg.length === 0;
|
return typeof result.svg !== "string" || result.svg.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pdf-reader's failure brief (mirrors the research failure shape). */
|
||||||
|
function isPdfFailure(result: PdfResult): boolean {
|
||||||
|
return /^PDF failed:/i.test(result.brief);
|
||||||
|
}
|
||||||
|
|
||||||
/** The activity line shown in the UI: `web_search: "query"` / `web_fetch: host`. */
|
/** The activity line shown in the UI: `web_search: "query"` / `web_fetch: host`. */
|
||||||
function activityLine(toolName: string, input: { query?: string; url?: string } | undefined): string {
|
function activityLine(toolName: string, input: { query?: string; url?: string } | undefined): string {
|
||||||
if (toolName === "web_search") return `web_search: "${input?.query ?? ""}"`;
|
if (toolName === "web_search") return `web_search: "${input?.query ?? ""}"`;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { JobStore } from "../core/ports.ts";
|
|||||||
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
|
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
|
||||||
import { planTools, type PlanToolContext } from "./tools/plan.ts";
|
import { planTools, type PlanToolContext } from "./tools/plan.ts";
|
||||||
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
||||||
|
import { readTool } from "./tools/read.ts";
|
||||||
|
|
||||||
export { jobTools };
|
export { jobTools };
|
||||||
export type { ProbeDoneContext } from "./tools/probe.ts";
|
export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||||
@@ -80,6 +81,9 @@ export function createProberTools(
|
|||||||
{
|
{
|
||||||
quiz: probeQuizTool,
|
quiz: probeQuizTool,
|
||||||
ask_user_question: askUserQuestionTool,
|
ask_user_question: askUserQuestionTool,
|
||||||
|
// read is always-on (user-decided 2026-09): the prober inspects
|
||||||
|
// learner-provided source files while assessing the edge.
|
||||||
|
read: readTool,
|
||||||
...done,
|
...done,
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
@@ -164,6 +168,9 @@ export function createTeacherTools(
|
|||||||
{
|
{
|
||||||
quiz: teachQuizToolFor(ctx),
|
quiz: teachQuizToolFor(ctx),
|
||||||
ask_user_question: askUserQuestionTool,
|
ask_user_question: askUserQuestionTool,
|
||||||
|
// read is always-on (user-decided 2026-09): the teacher reads
|
||||||
|
// learner-provided sources on its own terms — nothing auto-injects.
|
||||||
|
read: readTool,
|
||||||
...planTools(ctx),
|
...planTools(ctx),
|
||||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
||||||
},
|
},
|
||||||
|
|||||||
+28
-2
@@ -21,7 +21,7 @@ export interface JobToolContext {
|
|||||||
/** Fire the background runner for a fresh job — fire-and-forget. The
|
/** Fire the background runner for a fresh job — fire-and-forget. The
|
||||||
* input rides along because the spawn happens MID-STREAM, before
|
* input rides along because the spawn happens MID-STREAM, before
|
||||||
* onFinish persists the tool part — a transcript scan would race it. */
|
* onFinish persists the tool part — a transcript scan would race it. */
|
||||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void;
|
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const researchInputSchema = z.object({
|
export const researchInputSchema = z.object({
|
||||||
@@ -53,6 +53,16 @@ export const visualInputSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const pdfInputSchema = z.object({
|
||||||
|
path: z.string().describe("Path to the learner's PDF (relative or absolute), exactly as they gave it."),
|
||||||
|
question: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"What you need from the document — the specific content, explanation, or answer to extract and brief. " +
|
||||||
|
"The reader sees NOTHING else of the lesson.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-call dispatch: create the job (idempotent by toolCallId), fire the
|
* The per-call dispatch: create the job (idempotent by toolCallId), fire the
|
||||||
* runner, and return the ack. Shared by all three tools — only kind differs.
|
* runner, and return the ack. Shared by all three tools — only kind differs.
|
||||||
@@ -62,7 +72,7 @@ function dispatch(
|
|||||||
kind: JobRecord["kind"],
|
kind: JobRecord["kind"],
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
ack: string,
|
ack: string,
|
||||||
input: ResearchInput | DemoInput | VisualInput,
|
input: ResearchInput | DemoInput | VisualInput | PdfInput,
|
||||||
): string {
|
): string {
|
||||||
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
||||||
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
|
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
|
||||||
@@ -79,6 +89,9 @@ const DEMO_ACK =
|
|||||||
const VISUAL_ACK =
|
const VISUAL_ACK =
|
||||||
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
|
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
|
||||||
"The verified figure arrives as a new message when ready and is rendered in the lesson; reference it then.";
|
"The verified figure arrives as a new message when ready and is rendered in the lesson; reference it then.";
|
||||||
|
const PDF_ACK =
|
||||||
|
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
|
||||||
|
"The document brief arrives as a new message when ready; weave it into the lesson and cite its pages.";
|
||||||
|
|
||||||
const RESEARCH_DESCRIPTION =
|
const RESEARCH_DESCRIPTION =
|
||||||
"Dispatch a background web-research agent: fact verification, current data, or niche specifics you are not certain about. " +
|
"Dispatch a background web-research agent: fact verification, current data, or niche specifics you are not certain about. " +
|
||||||
@@ -97,6 +110,12 @@ const VISUAL_DESCRIPTION =
|
|||||||
"function plots, physical layouts. For flows/relations/taxonomies write mermaid inline instead. " +
|
"function plots, physical layouts. For flows/relations/taxonomies write mermaid inline instead. " +
|
||||||
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the verified figure arrives later " +
|
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the verified figure arrives later " +
|
||||||
"as a new message.";
|
"as a new message.";
|
||||||
|
const PDF_DESCRIPTION =
|
||||||
|
"Dispatch a background sub-agent that READS a learner-provided PDF on your behalf (triage, targeted extraction, visual page " +
|
||||||
|
"reading for equations and diagrams) and returns a brief with page citations. Use it when the learner gives you a PDF path " +
|
||||||
|
"and you need its CONTENT for the lesson. The call is ASYNC and fire-and-forget — it resolves immediately and your turn " +
|
||||||
|
"continues; the brief arrives later as a new message. For quick looks at plain text or image files, use the read tool " +
|
||||||
|
"directly instead of dispatching.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request job tool registry: each tool closes over the composition root's
|
* Per-request job tool registry: each tool closes over the composition root's
|
||||||
@@ -123,9 +142,16 @@ export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>>
|
|||||||
schema: visualInputSchema,
|
schema: visualInputSchema,
|
||||||
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||||
}),
|
}),
|
||||||
|
pdf: lhTool({
|
||||||
|
name: "pdf",
|
||||||
|
description: PDF_DESCRIPTION,
|
||||||
|
schema: pdfInputSchema,
|
||||||
|
execute: async (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResearchInput = z.infer<typeof researchInputSchema>;
|
export type ResearchInput = z.infer<typeof researchInputSchema>;
|
||||||
export type DemoInput = z.infer<typeof demoInputSchema>;
|
export type DemoInput = z.infer<typeof demoInputSchema>;
|
||||||
export type VisualInput = z.infer<typeof visualInputSchema>;
|
export type VisualInput = z.infer<typeof visualInputSchema>;
|
||||||
|
export type PdfInput = z.infer<typeof pdfInputSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { lhTool, type LhToolImageResult } from "../lh-tool.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read tool — sources v0 (2026-09): a Node port of pi's read semantics
|
||||||
|
* for the learner's local files. The flow is learner-owned: he pastes file
|
||||||
|
* paths into the chat, the agent reads on its own terms (nothing is
|
||||||
|
* auto-injected). Unrestricted local reads are the user-decided v0 scope —
|
||||||
|
* this is a personal study harness, not a multi-tenant service.
|
||||||
|
*
|
||||||
|
* Ported behaviors (mirroring pi's dist/core/tools/read.js):
|
||||||
|
* - text files: utf-8, offset (1-indexed) / limit slicing, and the
|
||||||
|
* 2000-line / 50KB truncation ceiling with actionable "use offset=N"
|
||||||
|
* continuation notices — a huge file never silently floods the context.
|
||||||
|
* - image files (jpg/png/gif/webp/bmp): returned as native image content
|
||||||
|
* parts so the vision-capable model actually SEES them (teacher's
|
||||||
|
* glm-5.3-flash accepts image input per pi's zai catalog; the converter
|
||||||
|
* in lh-tool.ts maps LhToolImageResult to pi's ImageContent).
|
||||||
|
* - unreadable/missing files throw — pi turns that into an error tool
|
||||||
|
* result the model sees and recovers from.
|
||||||
|
*
|
||||||
|
* Usage guidance ships in the description (the self-contained-tools
|
||||||
|
* principle, user-decided 2026-09: system prompts reference only
|
||||||
|
* always-on tools — read IS always-on, but its doctrine still lives here).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_LINES = 2000;
|
||||||
|
const MAX_BYTES = 50 * 1024;
|
||||||
|
|
||||||
|
/** Extension map for v0 (pi sniffs magic bytes; extensions suffice here —
|
||||||
|
* learner files are pasted paths, not adversarial uploads). */
|
||||||
|
const IMAGE_MIME: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
const readInputSchema = z.object({
|
||||||
|
path: z.string().min(1).describe("Path to the file to read (relative or absolute)"),
|
||||||
|
offset: z.number().int().positive().optional().describe("Line number to start reading from (1-indexed)"),
|
||||||
|
limit: z.number().int().positive().optional().describe("Maximum number of lines to read"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)}KB` : `${bytes}B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slice + truncate the text body, pi's continuation-notice style. */
|
||||||
|
function readTextSlice(text: string, offset: number | undefined, limit: number | undefined): string {
|
||||||
|
const allLines = text.split("\n");
|
||||||
|
const totalLines = allLines.length;
|
||||||
|
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
||||||
|
if (startLine >= totalLines) {
|
||||||
|
throw new Error(`Offset ${offset} is beyond end of file (${totalLines} lines total)`);
|
||||||
|
}
|
||||||
|
// honor an explicit limit first (never beyond EOF)
|
||||||
|
let selected = allLines.slice(startLine, limit !== undefined ? startLine + limit : totalLines);
|
||||||
|
// then the 2000-line ceiling
|
||||||
|
let truncatedBy: "lines" | "bytes" | null = null;
|
||||||
|
if (selected.length > MAX_LINES) {
|
||||||
|
selected = selected.slice(0, MAX_LINES);
|
||||||
|
truncatedBy = "lines";
|
||||||
|
}
|
||||||
|
// then the 50KB ceiling
|
||||||
|
if (Buffer.byteLength(selected.join("\n"), "utf-8") > MAX_BYTES) {
|
||||||
|
while (selected.length > 0 && Buffer.byteLength(selected.join("\n"), "utf-8") > MAX_BYTES) {
|
||||||
|
selected.pop();
|
||||||
|
}
|
||||||
|
truncatedBy = "bytes";
|
||||||
|
}
|
||||||
|
if (truncatedBy === null) {
|
||||||
|
const remaining = totalLines - (startLine + selected.length);
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `${selected.join("\n")}\n\n[${remaining} more lines in file. Use offset=${startLine + selected.length + 1} to continue.]`;
|
||||||
|
}
|
||||||
|
return selected.join("\n");
|
||||||
|
}
|
||||||
|
const endLineDisplay = startLine + selected.length;
|
||||||
|
const reason =
|
||||||
|
truncatedBy === "lines" ? `${MAX_LINES} line limit` : `${formatSize(MAX_BYTES)} limit`;
|
||||||
|
return `${selected.join("\n")}\n\n[Showing lines ${startLine + 1}-${endLineDisplay} of ${totalLines} (${reason}). Use offset=${endLineDisplay + 1} to continue.]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The read tool, shared verbatim by the prober and the teacher. */
|
||||||
|
export const readTool = lhTool({
|
||||||
|
name: "read",
|
||||||
|
description:
|
||||||
|
"Read the contents of a local file — the learner's source material for this lesson " +
|
||||||
|
"(he pastes file paths into the chat; read them on your own terms, never ask him to " +
|
||||||
|
"paste file CONTENTS into the chat). Supports text files and images (jpg, png, gif, " +
|
||||||
|
"webp, bmp) — images are shown to you as attachments. Text output is truncated to " +
|
||||||
|
`2000 lines or 50KB (whichever is hit first); use offset/limit for large files, and ` +
|
||||||
|
"continue with offset until you have what you need.",
|
||||||
|
schema: readInputSchema,
|
||||||
|
execute: async ({ path: rawPath, offset, limit }): Promise<string | LhToolImageResult> => {
|
||||||
|
const absolute = path.resolve(process.cwd(), rawPath);
|
||||||
|
const ext = path.extname(absolute).toLowerCase();
|
||||||
|
const mime = IMAGE_MIME[ext];
|
||||||
|
const buffer = await fs.readFile(absolute); // ENOENT/EACCES → error result the model sees
|
||||||
|
if (mime) {
|
||||||
|
// NOTE v0: no resize — raw bytes as base64. Learner files are study
|
||||||
|
// material (screenshots, figures), not adversarial payloads; a resize
|
||||||
|
// step (sharp) can land later if large images prove wasteful.
|
||||||
|
return {
|
||||||
|
text: `Read image file [${mime}] — ${path.basename(absolute)} (${formatSize(buffer.byteLength)}), attached as an image.`,
|
||||||
|
images: [{ data: buffer.toString("base64"), mimeType: mime }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const text = buffer.toString("utf-8");
|
||||||
|
return readTextSlice(text, offset, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import type { CallUsage, GraphContextData } from "./chat.ts";
|
|||||||
|
|
||||||
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
||||||
|
|
||||||
export type JobKind = "research" | "demo" | "visual" | "graph-context";
|
export type JobKind = "research" | "demo" | "visual" | "pdf" | "graph-context";
|
||||||
export type JobStatus = "running" | "done" | "error";
|
export type JobStatus = "running" | "done" | "error";
|
||||||
|
|
||||||
export interface JobRecord {
|
export interface JobRecord {
|
||||||
@@ -119,7 +119,7 @@ export function accumulateJobUsage(
|
|||||||
* THE ASYNC PROTOCOL — "ack now, steer later" (the pi sub-agents model,
|
* THE ASYNC PROTOCOL — "ack now, steer later" (the pi sub-agents model,
|
||||||
* vision §2.5):
|
* vision §2.5):
|
||||||
*
|
*
|
||||||
* 1. Job tools (research / demo / visual) are SERVER-EXECUTED. The model
|
* 1. Job tools (research / demo / visual / pdf) are SERVER-EXECUTED. The model
|
||||||
* calls one MID-TURN; execute creates the job record, fires the
|
* calls one MID-TURN; execute creates the job record, fires the
|
||||||
* background runner, and resolves IMMEDIATELY with an acknowledgement
|
* background runner, and resolves IMMEDIATELY with an acknowledgement
|
||||||
* ("dispatched — continue your turn"). The teacher's turn does NOT end
|
* ("dispatched — continue your turn"). The teacher's turn does NOT end
|
||||||
@@ -213,6 +213,28 @@ export interface VisualResult {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pdf-reader (sources v0, 2026-09): reads a learner-provided PDF on
|
||||||
|
* the teacher's behalf — triage, targeted extraction, look-at-the-page
|
||||||
|
* renders inside its own loop — and returns a text brief with page
|
||||||
|
* citations (user-decided: rendered images never leave the sub-agent's
|
||||||
|
* context; the teacher gets text). */
|
||||||
|
export interface PdfToolInput {
|
||||||
|
/** Path to the learner's PDF (relative or absolute). */
|
||||||
|
path: string;
|
||||||
|
/** What the teacher needs from the document — focuses the brief. */
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfResult {
|
||||||
|
path: string;
|
||||||
|
question: string;
|
||||||
|
/** The distilled brief. Page citations inside the text point at
|
||||||
|
* pagesConsulted. Failure shape: /^PDF failed:/i (mirrors research). */
|
||||||
|
brief: string;
|
||||||
|
/** Pages the reader actually consulted. */
|
||||||
|
pagesConsulted: number[];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
|
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
|
||||||
|
|
||||||
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
|
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
|
||||||
@@ -230,7 +252,7 @@ export interface SubagentResultMeta {
|
|||||||
/** The data-subagent-artifact part payload: what the UI renders. */
|
/** The data-subagent-artifact part payload: what the UI renders. */
|
||||||
export interface SubagentArtifactData {
|
export interface SubagentArtifactData {
|
||||||
kind: JobKind;
|
kind: JobKind;
|
||||||
result: ResearchResult | DemoResult | VisualResult;
|
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -270,7 +292,10 @@ export type SteerMessage =
|
|||||||
| GraphContextSteerMessage;
|
| GraphContextSteerMessage;
|
||||||
|
|
||||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||||
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
|
export function isSubagentFailure(
|
||||||
|
kind: JobKind,
|
||||||
|
result: ResearchResult | DemoResult | VisualResult | PdfResult,
|
||||||
|
): boolean {
|
||||||
if (kind === "research") {
|
if (kind === "research") {
|
||||||
const r = result as ResearchResult;
|
const r = result as ResearchResult;
|
||||||
return r.sources.length === 0 && /^Research failed:/i.test(r.brief);
|
return r.sources.length === 0 && /^Research failed:/i.test(r.brief);
|
||||||
@@ -279,12 +304,16 @@ export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoRe
|
|||||||
const r = result as DemoResult;
|
const r = result as DemoResult;
|
||||||
return typeof r.html !== "string" || r.html.length === 0;
|
return typeof r.html !== "string" || r.html.length === 0;
|
||||||
}
|
}
|
||||||
|
if (kind === "pdf") {
|
||||||
|
const r = result as PdfResult;
|
||||||
|
return /^PDF failed:/i.test(r.brief);
|
||||||
|
}
|
||||||
const r = result as VisualResult;
|
const r = result as VisualResult;
|
||||||
return typeof r.svg !== "string" || r.svg.length === 0;
|
return typeof r.svg !== "string" || r.svg.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model-facing text of a steer: what the result IS, what to do with it. */
|
/** The model-facing text of a steer: what the result IS, what to do with it. */
|
||||||
function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): string {
|
function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualResult | PdfResult): string {
|
||||||
if (isSubagentFailure(kind, result)) {
|
if (isSubagentFailure(kind, result)) {
|
||||||
if (kind === "research")
|
if (kind === "research")
|
||||||
return `${(result as ResearchResult).brief}\n\nContinue with what you can establish and flag the uncertainty to the learner.`;
|
return `${(result as ResearchResult).brief}\n\nContinue with what you can establish and flag the uncertainty to the learner.`;
|
||||||
@@ -292,10 +321,14 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe
|
|||||||
return `The demo "${(result as DemoResult).title}" could not be built: ${
|
return `The demo "${(result as DemoResult).title}" could not be built: ${
|
||||||
(result as DemoResult).notes ?? "unknown reason"
|
(result as DemoResult).notes ?? "unknown reason"
|
||||||
}. Fall back to explaining in prose; if the brief was the problem, re-call demo with a tighter one.`;
|
}. Fall back to explaining in prose; if the brief was the problem, re-call demo with a tighter one.`;
|
||||||
|
if (kind === "pdf")
|
||||||
|
return `${(result as PdfResult).brief}\n\nContinue with what you can establish from the session and flag the gap to the learner.`;
|
||||||
return `The figure could not be made: ${(result as VisualResult).notes ?? "unknown reason"}. Fall back to prose or mermaid, or re-call visual with a tighter brief.`;
|
return `The figure could not be made: ${(result as VisualResult).notes ?? "unknown reason"}. Fall back to prose or mermaid, or re-call visual with a tighter brief.`;
|
||||||
}
|
}
|
||||||
if (kind === "research")
|
if (kind === "research")
|
||||||
return `Research complete — the researcher's full brief follows. Weave it into the lesson and cite its sources when you use its findings:\n\n${(result as ResearchResult).brief}`;
|
return `Research complete — the researcher's full brief follows. Weave it into the lesson and cite its sources when you use its findings:\n\n${(result as ResearchResult).brief}`;
|
||||||
|
if (kind === "pdf")
|
||||||
|
return `PDF reading complete — the reader's brief on "${(result as PdfResult).path}" follows, with page citations. Weave it into the lesson and cite pages when you use it:\n\n${(result as PdfResult).brief}`;
|
||||||
if (kind === "demo")
|
if (kind === "demo")
|
||||||
return `The demo "${(result as DemoResult).title}" is built, headlessly tested, and rendered in the lesson. Builder notes: ${
|
return `The demo "${(result as DemoResult).title}" is built, headlessly tested, and rendered in the lesson. Builder notes: ${
|
||||||
(result as DemoResult).notes ?? "(none)"
|
(result as DemoResult).notes ?? "(none)"
|
||||||
@@ -312,7 +345,7 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe
|
|||||||
*/
|
*/
|
||||||
export function buildSubagentSteer(
|
export function buildSubagentSteer(
|
||||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||||
result: ResearchResult | DemoResult | VisualResult,
|
result: ResearchResult | DemoResult | VisualResult | PdfResult,
|
||||||
): SubagentSteerMessage {
|
): SubagentSteerMessage {
|
||||||
return {
|
return {
|
||||||
id: `steer_${job.id}`,
|
id: `steer_${job.id}`,
|
||||||
@@ -352,6 +385,8 @@ function abortText(kind: JobKind): string {
|
|||||||
return "Research aborted — the learner aborted the research job, so no brief is coming. Acknowledge it in one line and continue teaching without it (or offer to re-dispatch it).";
|
return "Research aborted — the learner aborted the research job, so no brief is coming. Acknowledge it in one line and continue teaching without it (or offer to re-dispatch it).";
|
||||||
if (kind === "demo")
|
if (kind === "demo")
|
||||||
return "The demo job was aborted by the learner — no demo is coming. Acknowledge it in one line and teach the topic in prose instead (a re-dispatch is fine if the learner wants it).";
|
return "The demo job was aborted by the learner — no demo is coming. Acknowledge it in one line and teach the topic in prose instead (a re-dispatch is fine if the learner wants it).";
|
||||||
|
if (kind === "pdf")
|
||||||
|
return "The PDF reading job was aborted by the learner — no brief is coming. Acknowledge it in one line and continue without it (a re-dispatch is fine if the learner wants it).";
|
||||||
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
|
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -34,13 +34,14 @@ export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
|||||||
|
|
||||||
/** The tools a user may toggle off in settings (Wave 2 T2a, user-decided
|
/** The tools a user may toggle off in settings (Wave 2 T2a, user-decided
|
||||||
* 2026-09-15): for now only the SUB-AGENT dispatch tools — quiz,
|
* 2026-09-15): for now only the SUB-AGENT dispatch tools — quiz,
|
||||||
* ask_user_question (and the read tool, when it lands) are always on;
|
* ask_user_question and the read tool are always on;
|
||||||
* the long-term vision is that everything becomes toggleable as new
|
* the long-term vision is that everything becomes toggleable as new
|
||||||
* tool variants (e.g. alternative question tools) ship, so the factory
|
* tool variants (e.g. alternative question tools) ship, so the factory
|
||||||
* seam below is GENERIC: it honors any tool name it receives. New
|
* seam below is GENERIC: it honors any tool name it receives. New
|
||||||
* toggleables join this list when settings exposes them.
|
* toggleables join this list when settings exposes them.
|
||||||
* The names are the MODEL-FACING tool names (research, not researcher). */
|
* The names are the MODEL-FACING tool names (research, not researcher;
|
||||||
export const TOGGLEABLE_TOOLS = ["research", "demo", "visual"] as const;
|
* pdf is the PDF-reader dispatch, sources v0 2026-09). */
|
||||||
|
export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
||||||
|
|
||||||
/** The resolved settings → factory seam shape (Platform wires chat-handler
|
/** The resolved settings → factory seam shape (Platform wires chat-handler
|
||||||
* to pass this through from the settings record; storage and defaults are
|
* to pass this through from the settings record; storage and defaults are
|
||||||
|
|||||||
+42
-3
@@ -42,7 +42,7 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
|||||||
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
|
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
|
||||||
import { JobCard } from "../components/artifacts/job-card.tsx";
|
import { JobCard } from "../components/artifacts/job-card.tsx";
|
||||||
import { JobsContext } from "../components/jobs-context.ts";
|
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 { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||||
@@ -174,6 +174,9 @@ function noChromeHeadless(): HeadlessApi {
|
|||||||
renderSvg: async () => {
|
renderSvg: async () => {
|
||||||
throw new Error("should not render");
|
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");
|
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", () => {
|
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 steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
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", () => {
|
it("demo and visual are SERVER-EXECUTED dispatch tools with the contract schemas", () => {
|
||||||
const { runner } = testRunner();
|
const { runner } = testRunner();
|
||||||
const tools = jobTools({ chatId: "chat_registry", jobStore, spawn: (job) => void runner.runJob(job.id) });
|
const tools = jobTools({ chatId: "chat_registry", jobStore, spawn: (job) => void runner.runJob(job.id) });
|
||||||
assert.deepEqual(Object.keys(tools).sort(), ["demo", "research", "visual"]);
|
assert.deepEqual(Object.keys(tools).sort(), ["demo", "pdf", "research", "visual"]);
|
||||||
for (const name of ["demo", "visual", "research"] as const) {
|
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(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({ title: "T", brief: "b" }).success, true);
|
||||||
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
|
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
|
||||||
assert.equal(visualInputSchema.safeParse({ brief: "b" }).success, true);
|
assert.equal(visualInputSchema.safeParse({ brief: "b" }).success, true);
|
||||||
assert.equal(visualInputSchema.safeParse({}).success, false, "visual requires a brief");
|
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", () => {
|
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,
|
steerDelivered,
|
||||||
type DemoResult,
|
type DemoResult,
|
||||||
type JobUsage,
|
type JobUsage,
|
||||||
|
type PdfResult,
|
||||||
type ResearchResult,
|
type ResearchResult,
|
||||||
|
type SubagentArtifactData,
|
||||||
type SteerMessage,
|
type SteerMessage,
|
||||||
type SubagentSteerMessage,
|
type SubagentSteerMessage,
|
||||||
type VisualResult,
|
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 { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.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 type { PiEngine } from "../src/agent/models.ts";
|
||||||
import { PATHS } from "../config.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"));
|
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) ───────────
|
// ─── 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) ──────────────
|
// ─── 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", () => {
|
describe("researcher agent", () => {
|
||||||
it("runs with the web tool set (no network touched)", () => {
|
it("runs with the web tool set (no network touched)", () => {
|
||||||
// The researcher is a runSubagent call over the webTools registry —
|
// The researcher is a runSubagent call over the webTools registry —
|
||||||
@@ -568,6 +669,14 @@ describe("researcher agent", () => {
|
|||||||
// reset any accidental global override
|
// reset any accidental global override
|
||||||
researchModelOverride.current = undefined;
|
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) ────────────────────────
|
// ─── 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");
|
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)", () => {
|
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
|
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
|
||||||
// teacher turn until the numeric-prefix filter stopped it. This guard
|
// 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");
|
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 chatId = newChat("Prober: registry");
|
||||||
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
|
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);
|
const schema = quizSchemaOf(tools);
|
||||||
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
|
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) ───────────────────────────────
|
// ─── tool availability (2026-09 settings seam) ───────────────────────────────
|
||||||
|
|
||||||
describe("tool toggles (core/pending.ts + factories)", () => {
|
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.
|
// The user-toggleable vocabulary Platform's settings validates against.
|
||||||
// quiz/ask_user_question are always-on FOR NOW (user-decided 2026-09-15);
|
// quiz/ask_user_question/read are always-on FOR NOW (user-decided
|
||||||
// they join when settings exposes them — the factory seam is generic.
|
// 2026-09-15, sources v0); they join when settings exposes them — the
|
||||||
assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual"]);
|
// 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", () => {
|
it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => {
|
||||||
const chatId = newChat("Prober: toggles");
|
const chatId = newChat("Prober: toggles");
|
||||||
const ctx = { chatId, chatStore, deliver: () => {} };
|
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 });
|
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),
|
// quiz off is honored by the generic machinery too (future toggleable),
|
||||||
// but probe_done is never droppable — it is how the phase ends.
|
// but probe_done is never droppable — it is how the phase ends.
|
||||||
const noQuiz = createProberTools(ctx, { quiz: false });
|
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");
|
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: () => {},
|
spawn: () => {},
|
||||||
};
|
};
|
||||||
const full = Object.keys(createTeacherTools(ctx)).sort();
|
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();
|
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
|
// absent = enabled: an empty record and undefined behave identically
|
||||||
assert.deepEqual(
|
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