Merge branch 'feat/sub-agents'
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
|
||||
@@ -232,6 +232,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
||||
research: SearchIcon,
|
||||
demo: PlayIcon,
|
||||
visual: PenToolIcon,
|
||||
pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf")
|
||||
"graph-context": BookOpenIcon,
|
||||
};
|
||||
|
||||
@@ -347,9 +348,11 @@ function ActivityBlobRow({
|
||||
? "Demo builder"
|
||||
: blob.jobKind === "visual"
|
||||
? "Figure maker"
|
||||
: blob.jobKind === "graph-context"
|
||||
? "Graph context"
|
||||
: "Sub-agent";
|
||||
: blob.jobKind === "pdf"
|
||||
? "PDF reader"
|
||||
: blob.jobKind === "graph-context"
|
||||
? "Graph context"
|
||||
: "Sub-agent";
|
||||
return (
|
||||
<div
|
||||
data-testid={`activity-blob-${blob.process}`}
|
||||
|
||||
@@ -21,6 +21,7 @@ const KIND_LABEL: Record<JobKind, string> = {
|
||||
research: "Research agent",
|
||||
demo: "Demo agent",
|
||||
visual: "Figure agent",
|
||||
pdf: "PDF reader",
|
||||
// The injection is server-initiated — it has no tool part, so never a
|
||||
// dispatch card. The entry exists only for the exhaustive record.
|
||||
"graph-context": "Graph context",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||
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).
|
||||
@@ -18,6 +18,39 @@ export function JobCard({ part }: ArtifactPartProps) {
|
||||
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 steer card (sub-agent result delivery) renders the same artifact.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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 { 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";
|
||||
|
||||
/**
|
||||
@@ -48,6 +48,12 @@ export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
||||
) : (
|
||||
<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":
|
||||
return <DemoOutputCard output={data.result} />;
|
||||
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
|
||||
* teacher's wake turn; the badge just marks delivery. The persisted brief
|
||||
* rides along in a collapsed <details> (the wave-1 "view full research"
|
||||
|
||||
@@ -62,6 +62,13 @@ export const MODELS = {
|
||||
demoBuilder: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
||||
/** Must be vision-capable: it looks at rendered SVGs. */
|
||||
visualMaker: { model: "qwen/qwen3.8-flash", effort: "low" },
|
||||
/** SEAM (master-approved 2026-09, revised by the user 2026-09-19): the
|
||||
* pdf-reader sub-agent. Must be vision-capable — it looks at rendered
|
||||
* PDF pages; its briefs are text. The user overrode the initial
|
||||
* glm-5.3-flash pick (catalog says it accepts images, but qwen is
|
||||
* 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. */
|
||||
extractor: { model: "z-ai/glm-5.3-flash", effort: "low" },
|
||||
/** The auditor verifies evidence exists; keep it cheap. */
|
||||
|
||||
Generated
+278
@@ -40,6 +40,7 @@
|
||||
"nanoid": "^6.0.1",
|
||||
"next": "^16.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"puppeteer-core": "^25.8.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.8",
|
||||
@@ -3531,6 +3532,271 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
|
||||
@@ -14984,6 +15250,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"nanoid": "^6.0.1",
|
||||
"next": "^16.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"puppeteer-core": "^25.8.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.8",
|
||||
|
||||
+40
-4
@@ -42,6 +42,27 @@ export function lhTool<TInput, TResult = unknown>(def: LhTool<TInput, TResult>):
|
||||
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. */
|
||||
export class ToolInputError extends Error {
|
||||
constructor(
|
||||
@@ -91,11 +112,26 @@ export function toPiTool(def: LhTool<any, any>): AgentTool<any, any> {
|
||||
};
|
||||
}
|
||||
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 {
|
||||
// The model's view: today's parity — string results pass verbatim,
|
||||
// objects as JSON.
|
||||
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
|
||||
details: result,
|
||||
// The model's view: today's parity — string results pass verbatim,
|
||||
// objects as JSON.
|
||||
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ Probing is not a warm-up. It is the whole job of this phase, and it is the highe
|
||||
|
||||
## 1a. His current level — use `quiz`. This is a mapping job, not a spot-check.
|
||||
|
||||
Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, the teacher cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush.
|
||||
Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, the teacher cannot teach into it. And measurement should be efficient: every question you ask should move an edge somewhere — bracket a strand, narrow a bracket, or characterize a miss. No filler questions, no re-asking what his last answer already showed you. When a strand is bracketed, it's done: move to the next.
|
||||
|
||||
**The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing.
|
||||
|
||||
@@ -21,4 +21,6 @@ Find out what he actually wants taught. With a subject he doesn't know yet, the
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Question principles — what a teach-phase check must be
|
||||
|
||||
Every check exists to answer one question: did the idea actually **take** — not whether he can read it back. The principles:
|
||||
|
||||
- **Answerable by reasoning from the session.** Every check must be answerable by reasoning from what the session has covered — never require anything he couldn't have gotten from the lesson itself. A check he has no route to is not a check, it's a coin flip.
|
||||
- **Never just reciting your words back.** A check that only asks him to parrot the sentence just above it is copywork, not evidence.
|
||||
- **Prefer problems that apply the knowledge.** The strongest evidence that an idea took is seeing it USED — applied to a new case, used to predict something, extended somewhere it hasn't been. Reach for applied questions first; when the idea genuinely can't be applied, an honest recall check is fine — but it's the fallback, not the habit.
|
||||
- **Catch confusion early, and make it nameable.** Design the wrong options as real misconceptions — each the answer he'd give under a specific wrong model. Then a miss TELLS you which wrong model he holds; diagnose and repair before building anything on top of it.
|
||||
- **His answer is signal, not verdict.** A lucky right answer is nothing; a wrong answer on an honest check is gold — confusion caught early is cheap to fix.
|
||||
@@ -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"
|
||||
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).
|
||||
|
||||
## 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 os from "node:os";
|
||||
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):
|
||||
@@ -46,6 +46,16 @@ export interface RenderSvgOptions {
|
||||
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 {
|
||||
/** True when a Chrome executable is resolvable (cheap, no launch). */
|
||||
available(): boolean;
|
||||
@@ -55,6 +65,9 @@ export interface HeadlessApi {
|
||||
runDemo(html: string, opts?: RunDemoOptions): Promise<DemoRunResult>;
|
||||
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
|
||||
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. */
|
||||
@@ -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. " +
|
||||
"(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 executablePath = resolveChromeExecutablePath();
|
||||
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
|
||||
// system Chrome install itself via the "chrome" channel.
|
||||
...(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;
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
export const realHeadless: HeadlessApi = {
|
||||
@@ -286,4 +389,5 @@ export const realHeadless: HeadlessApi = {
|
||||
},
|
||||
runDemo,
|
||||
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 JobRecord,
|
||||
type JobUsage,
|
||||
type PdfResult,
|
||||
type PdfToolInput,
|
||||
type ResearchResult,
|
||||
type ResearchToolInput,
|
||||
type SubagentSteerMessage,
|
||||
@@ -20,6 +22,7 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
||||
import { MODELS } from "../../../config.ts";
|
||||
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
||||
import { runDemoBuilder } from "./demo-builder.ts";
|
||||
import { runPdfReaderAgent } from "./pdf-reader.ts";
|
||||
import { runResearcherAgent } from "./researcher.ts";
|
||||
import { runVisualMaker } from "./visual-maker.ts";
|
||||
|
||||
@@ -51,9 +54,10 @@ import { runVisualMaker } from "./visual-maker.ts";
|
||||
export const researchModelOverride: { current?: PiEngine } = {};
|
||||
export const demoModelOverride: { current?: PiEngine } = {};
|
||||
export const visualModelOverride: { current?: PiEngine } = {};
|
||||
export const pdfModelOverride: { current?: PiEngine } = {};
|
||||
|
||||
/** 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 {
|
||||
chatStore: ChatStore;
|
||||
@@ -71,7 +75,7 @@ export interface JobRunner {
|
||||
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
||||
* before the tool part is persisted — only crash-recovery re-spawns
|
||||
* 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
|
||||
@@ -96,6 +100,7 @@ const KIND_MODEL: Record<JobKind, string> = {
|
||||
research: MODELS.researcher.model,
|
||||
demo: MODELS.demoBuilder.model,
|
||||
visual: MODELS.visualMaker.model,
|
||||
pdf: MODELS.pdfReader.model,
|
||||
"graph-context": "",
|
||||
};
|
||||
|
||||
@@ -145,6 +150,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
|
||||
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.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> {
|
||||
@@ -185,15 +191,11 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
function resolveInput(
|
||||
job: JobRecord,
|
||||
input: JobToolInput | undefined,
|
||||
): ResearchToolInput | DemoToolInput | VisualToolInput | undefined {
|
||||
): JobToolInput | undefined {
|
||||
if (input) return input;
|
||||
const chat = chatStore.getChat(job.chatId);
|
||||
if (!chat) return undefined;
|
||||
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as
|
||||
| ResearchToolInput
|
||||
| DemoToolInput
|
||||
| VisualToolInput
|
||||
| undefined;
|
||||
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as JobToolInput | undefined;
|
||||
}
|
||||
|
||||
// ─── shared plumbing ────────────────────────────────────────────────────
|
||||
@@ -213,7 +215,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
* actually resumed — until then the job reads "running" so the client's
|
||||
* 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);
|
||||
if (!job) return;
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -386,6 +426,11 @@ function isVisualFailure(result: VisualResult): boolean {
|
||||
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`. */
|
||||
function activityLine(toolName: string, input: { query?: string; url?: string } | undefined): string {
|
||||
if (toolName === "web_search") return `web_search: "${input?.query ?? ""}"`;
|
||||
|
||||
+49
-12
@@ -5,11 +5,13 @@ import {
|
||||
quizInputObject,
|
||||
quizInputProblems,
|
||||
} from "../core/question-core.ts";
|
||||
import { filterEnabledTools, type EnabledTools } from "../core/pending.ts";
|
||||
import type { JobRecord } from "../core/contracts.ts";
|
||||
import type { JobStore } from "../core/ports.ts";
|
||||
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
|
||||
import { planTools, type PlanToolContext } from "./tools/plan.ts";
|
||||
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
||||
import { readTool } from "./tools/read.ts";
|
||||
|
||||
export { jobTools };
|
||||
export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||
@@ -62,12 +64,31 @@ export const probeQuizTool: LhTool<any, any> = {
|
||||
schema: probeQuizInputSchema,
|
||||
};
|
||||
|
||||
/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. */
|
||||
export function createProberTools(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
|
||||
/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done.
|
||||
* `enabled` is the resolved settings seam (core/pending.ts): any tool
|
||||
* explicitly false is dropped from the registry. probe_done stays
|
||||
* always-on regardless — it is how the phase ENDS; a prober that can
|
||||
* never conclude is a lockout. */
|
||||
export function createProberTools(
|
||||
ctx: ProbeDoneContext,
|
||||
enabled?: EnabledTools,
|
||||
): Record<string, LhTool<any, any>> {
|
||||
// probe_done is re-added AFTER the filter, always-on by construction — it
|
||||
// is how the phase ENDS; a prober that can never conclude is a lockout.
|
||||
const done = probeDoneTool(ctx);
|
||||
return {
|
||||
quiz: probeQuizTool,
|
||||
ask_user_question: askUserQuestionTool,
|
||||
...probeDoneTool(ctx),
|
||||
...filterEnabledTools(
|
||||
{
|
||||
quiz: probeQuizTool,
|
||||
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,
|
||||
},
|
||||
enabled,
|
||||
),
|
||||
...done,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,12 +152,28 @@ export interface TeacherToolContext extends PlanToolContext {
|
||||
/**
|
||||
* Per-request teacher tool registry: teach questions (validated nodeId) +
|
||||
* plan tools + job tools, all closing over the ctx.
|
||||
*
|
||||
* `enabled` is the resolved settings seam (core/pending.ts EnabledTools —
|
||||
* Platform's chat-handler wiring passes it through from settings). Today
|
||||
* only the sub-agent tools (research/demo/visual) are user-toggleable, but
|
||||
* the filtering is GENERIC: any name explicitly false is dropped, so
|
||||
* future toggleables (question-tool variants, …) need no change here.
|
||||
* Absent/undefined = enabled — tests and old records see today's set.
|
||||
*/
|
||||
export function createTeacherTools(ctx: TeacherToolContext): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
quiz: teachQuizToolFor(ctx),
|
||||
ask_user_question: askUserQuestionTool,
|
||||
...planTools(ctx),
|
||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
||||
};
|
||||
export function createTeacherTools(
|
||||
ctx: TeacherToolContext,
|
||||
enabled?: EnabledTools,
|
||||
): Record<string, LhTool<any, any>> {
|
||||
return filterEnabledTools(
|
||||
{
|
||||
quiz: teachQuizToolFor(ctx),
|
||||
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),
|
||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
||||
},
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
+28
-2
@@ -21,7 +21,7 @@ export interface JobToolContext {
|
||||
/** Fire the background runner for a fresh job — fire-and-forget. The
|
||||
* input rides along because the spawn happens MID-STREAM, before
|
||||
* 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({
|
||||
@@ -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
|
||||
* runner, and return the ack. Shared by all three tools — only kind differs.
|
||||
@@ -62,7 +72,7 @@ function dispatch(
|
||||
kind: JobRecord["kind"],
|
||||
toolCallId: string,
|
||||
ack: string,
|
||||
input: ResearchInput | DemoInput | VisualInput,
|
||||
input: ResearchInput | DemoInput | VisualInput | PdfInput,
|
||||
): string {
|
||||
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
||||
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
|
||||
@@ -79,6 +89,9 @@ const DEMO_ACK =
|
||||
const VISUAL_ACK =
|
||||
"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.";
|
||||
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 =
|
||||
"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. " +
|
||||
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the verified figure arrives later " +
|
||||
"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
|
||||
@@ -123,9 +142,16 @@ export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>>
|
||||
schema: visualInputSchema,
|
||||
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 DemoInput = z.infer<typeof demoInputSchema>;
|
||||
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) ────────────
|
||||
|
||||
export type JobKind = "research" | "demo" | "visual" | "graph-context";
|
||||
export type JobKind = "research" | "demo" | "visual" | "pdf" | "graph-context";
|
||||
export type JobStatus = "running" | "done" | "error";
|
||||
|
||||
export interface JobRecord {
|
||||
@@ -119,7 +119,7 @@ export function accumulateJobUsage(
|
||||
* THE ASYNC PROTOCOL — "ack now, steer later" (the pi sub-agents model,
|
||||
* 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
|
||||
* background runner, and resolves IMMEDIATELY with an acknowledgement
|
||||
* ("dispatched — continue your turn"). The teacher's turn does NOT end
|
||||
@@ -213,6 +213,28 @@ export interface VisualResult {
|
||||
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) ─────────────────────
|
||||
|
||||
/** 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. */
|
||||
export interface SubagentArtifactData {
|
||||
kind: JobKind;
|
||||
result: ResearchResult | DemoResult | VisualResult;
|
||||
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,7 +292,10 @@ export type SteerMessage =
|
||||
| GraphContextSteerMessage;
|
||||
|
||||
/** 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") {
|
||||
const r = result as ResearchResult;
|
||||
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;
|
||||
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;
|
||||
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. */
|
||||
function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): string {
|
||||
function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualResult | PdfResult): string {
|
||||
if (isSubagentFailure(kind, result)) {
|
||||
if (kind === "research")
|
||||
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: ${
|
||||
(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.`;
|
||||
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.`;
|
||||
}
|
||||
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}`;
|
||||
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")
|
||||
return `The demo "${(result as DemoResult).title}" is built, headlessly tested, and rendered in the lesson. Builder notes: ${
|
||||
(result as DemoResult).notes ?? "(none)"
|
||||
@@ -312,7 +345,7 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe
|
||||
*/
|
||||
export function buildSubagentSteer(
|
||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||
result: ResearchResult | DemoResult | VisualResult,
|
||||
result: ResearchResult | DemoResult | VisualResult | PdfResult,
|
||||
): SubagentSteerMessage {
|
||||
return {
|
||||
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).";
|
||||
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).";
|
||||
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).";
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,39 @@ export const QUESTION_TOOLS = ["quiz", "ask_user_question"] as const;
|
||||
/** Tools fulfilled by background sub-agent jobs (W2/W6). */
|
||||
export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
||||
|
||||
// ─── Tool availability (2026-09 settings seam) ─────────────────────────────
|
||||
|
||||
/** 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,
|
||||
* ask_user_question and the read tool are always on;
|
||||
* the long-term vision is that everything becomes toggleable as new
|
||||
* tool variants (e.g. alternative question tools) ship, so the factory
|
||||
* seam below is GENERIC: it honors any tool name it receives. New
|
||||
* toggleables join this list when settings exposes them.
|
||||
* The names are the MODEL-FACING tool names (research, not researcher;
|
||||
* 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
|
||||
* to pass this through from the settings record; storage and defaults are
|
||||
* Platform's lane). JSON-native: a partial record keyed by tool name.
|
||||
* Absent key / undefined = enabled — old records behave exactly as today.
|
||||
* Any explicit `false` removes that tool from the registry, whatever its
|
||||
* name — the machinery stays generic for future toggleables. */
|
||||
export type EnabledTools = Partial<Record<string, boolean>>;
|
||||
|
||||
/** Drop every tool the resolved settings mark as disabled. The single
|
||||
* filtering site every factory shares (prober/teacher). */
|
||||
export function filterEnabledTools<T>(tools: Record<string, T>, enabled?: EnabledTools): Record<string, T> {
|
||||
if (!enabled) return tools;
|
||||
const out: Record<string, T> = {};
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
if (enabled[name] === false) continue;
|
||||
out[name] = tool;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null {
|
||||
for (let m = messages.length - 1; m >= 0; m--) {
|
||||
const msg = messages[m]!;
|
||||
|
||||
+42
-3
@@ -42,7 +42,7 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
||||
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
|
||||
import { JobCard } from "../components/artifacts/job-card.tsx";
|
||||
import { JobsContext } from "../components/jobs-context.ts";
|
||||
import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts";
|
||||
import { demoInputSchema, jobTools, pdfInputSchema, visualInputSchema } from "../src/agent/tools/jobs.ts";
|
||||
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
@@ -174,6 +174,9 @@ function noChromeHeadless(): HeadlessApi {
|
||||
renderSvg: async () => {
|
||||
throw new Error("should not render");
|
||||
},
|
||||
renderPdfPages: async () => {
|
||||
throw new Error("should not render");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,6 +372,39 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
assert.ok(html.includes("britannica.com/plato"), "source links ride inside the details");
|
||||
});
|
||||
|
||||
it("renders a quiet PDF-reading-complete one-liner (sources v0) whose collapsed details carries the brief + pages", () => {
|
||||
const steer = buildSubagentSteer(
|
||||
{ id: "jpf", kind: "pdf", toolCallId: "cpf" },
|
||||
{
|
||||
path: "/Users/x/course/chapter-3.pdf",
|
||||
question: "What is the integral of x dx?",
|
||||
brief: "The power rule appears on **page 1**: the integral of x dx is x^2/2 + C.",
|
||||
pagesConsulted: [1, 2],
|
||||
},
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /PDF/);
|
||||
assert.match(html, /reading complete/);
|
||||
assert.match(html, /2 pages consulted/);
|
||||
assert.match(html, /<details[^>]*data-testid="pdf-complete-badge"/, "a real disclosure like the research badge");
|
||||
assert.ok(!/<details[^>]*open/.test(html), "collapsed by default");
|
||||
assert.ok(html.includes("power rule"), "the brief rides inside the details");
|
||||
// the path/question are sub-agent context — never learner-facing chrome
|
||||
assert.ok(!html.includes("/Users/x/course/chapter-3.pdf"), "the file path never renders");
|
||||
});
|
||||
|
||||
it("renders the PDF failure brief with the failed badge when the reader FAILED", () => {
|
||||
const steer = buildSubagentSteer(
|
||||
{ id: "jpf2", kind: "pdf", toolCallId: "cpf2" },
|
||||
{ path: "/tmp/doc.pdf", question: "q", brief: "PDF failed: the file could not be opened", pagesConsulted: [] },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
assert.match(html, /pdf-brief-card/);
|
||||
assert.match(html, /failed/);
|
||||
assert.match(html, /could not be opened/);
|
||||
assert.ok(!html.includes("pdf-complete-badge"), "no complete badge for a failure");
|
||||
});
|
||||
|
||||
it("renders the visual failure note with no svg when the build FAILED", () => {
|
||||
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
@@ -750,14 +786,17 @@ describe("tool + renderer registry wiring", () => {
|
||||
it("demo and visual are SERVER-EXECUTED dispatch tools with the contract schemas", () => {
|
||||
const { runner } = testRunner();
|
||||
const tools = jobTools({ chatId: "chat_registry", jobStore, spawn: (job) => void runner.runJob(job.id) });
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["demo", "research", "visual"]);
|
||||
for (const name of ["demo", "visual", "research"] as const) {
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["demo", "pdf", "research", "visual"]);
|
||||
for (const name of ["demo", "visual", "research", "pdf"] as const) {
|
||||
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`);
|
||||
}
|
||||
assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true);
|
||||
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
|
||||
assert.equal(visualInputSchema.safeParse({ brief: "b" }).success, true);
|
||||
assert.equal(visualInputSchema.safeParse({}).success, false, "visual requires a brief");
|
||||
assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf", question: "q" }).success, true);
|
||||
assert.equal(pdfInputSchema.safeParse({ question: "q" }).success, false, "pdf requires a path");
|
||||
assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf" }).success, false, "pdf requires a question");
|
||||
});
|
||||
|
||||
it("the artifact renderer registry maps demo/visual to the W6 cards", () => {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
this is a text file, not a pdf
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 9 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 89 >>
|
||||
stream
|
||||
BT /F1 14 Tf 72 720 Td (Calculus page: the integral of x dx is x squared over two) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Length 82 >>
|
||||
stream
|
||||
BT /F1 14 Tf 72 720 Td (Physics page: force equals mass times acceleration) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
7 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R /Resources << /Font << /F1 9 0 R >> >> >>
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Length 60 >>
|
||||
stream
|
||||
BT /F1 14 Tf 72 720 Td (Chemistry page: H2O is water) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000127 00000 n
|
||||
0000000253 00000 n
|
||||
0000000391 00000 n
|
||||
0000000517 00000 n
|
||||
0000000648 00000 n
|
||||
0000000774 00000 n
|
||||
0000000883 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R >>
|
||||
startxref
|
||||
953
|
||||
%%EOF
|
||||
+141
-1
@@ -37,7 +37,9 @@ import {
|
||||
steerDelivered,
|
||||
type DemoResult,
|
||||
type JobUsage,
|
||||
type PdfResult,
|
||||
type ResearchResult,
|
||||
type SubagentArtifactData,
|
||||
type SteerMessage,
|
||||
type SubagentSteerMessage,
|
||||
type VisualResult,
|
||||
@@ -55,7 +57,7 @@ import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { createJobRunner, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import { createJobRunner, pdfModelOverride, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
@@ -288,6 +290,24 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
}
|
||||
assert.ok(failResearch.parts[0].type === "text" && failResearch.parts[0].text.includes("flag the uncertainty"));
|
||||
});
|
||||
|
||||
it("pdf steers (sources v0): brief + page citations on success, PDF failed: shape on failure, abort wording per kind", () => {
|
||||
const pdf: PdfResult = { path: "/tmp/doc.pdf", question: "q?", brief: "The formula is on p. 2.", pagesConsulted: [1, 2] };
|
||||
const ok = buildSubagentSteer({ id: "jp", kind: "pdf", toolCallId: "cp" }, pdf);
|
||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("page citations"), "the teacher is told to cite pages");
|
||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("The formula is on p. 2."));
|
||||
assert.equal(ok.parts[1].type, "data-subagent-artifact");
|
||||
assert.equal((ok.parts[1] as { data: SubagentArtifactData }).data.kind, "pdf");
|
||||
assert.deepEqual((ok.parts[1] as { data: SubagentArtifactData }).data.result, pdf);
|
||||
// failure shape mirrors research
|
||||
assert.equal(isSubagentFailure("pdf", pdf), false);
|
||||
assert.equal(isSubagentFailure("pdf", { ...pdf, brief: "PDF failed: unreadable" }), true);
|
||||
const fail = buildSubagentSteer({ id: "jf", kind: "pdf", toolCallId: "cf" }, { ...pdf, brief: "PDF failed: unreadable" });
|
||||
assert.ok(fail.parts[0].type === "text" && fail.parts[0].text.includes("flag the gap"));
|
||||
// abort wording names the PDF job
|
||||
const aborted = buildAbortSteer({ id: "ja", kind: "pdf", toolCallId: "ca" });
|
||||
assert.ok(aborted.parts[0].type === "text" && aborted.parts[0].text.includes("PDF reading job"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fake deliver (the wake pipeline's stand-in for runner tests) ───────────
|
||||
@@ -558,6 +578,87 @@ describe("research runner (async protocol §3)", () => {
|
||||
|
||||
// ─── the researcher agent construction (model resolution seam) ──────────────
|
||||
|
||||
|
||||
describe("pdf runner (sources v0)", () => {
|
||||
const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
const PDF_INPUT = { path: PDF_FIXTURE, question: "What is the integral of x dx?" };
|
||||
|
||||
/** A pdf-reader that triages, extracts pages 1-2, then briefs. */
|
||||
function pdfReaderModel(): PiEngine {
|
||||
return fauxEngine([
|
||||
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "1-2" }, { id: "e1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("The integral rule appears on page 1 (p. 1): x dx integrates to x^2/2.")]),
|
||||
]);
|
||||
}
|
||||
|
||||
it("runJob completes a pdf job: brief + pagesConsulted land on the record, citation steer delivered", async () => {
|
||||
const chat = chatStore.createChat("Test: pdf runner");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "read this pdf" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-pdf", toolCallId: "call-pdf1", state: "output-available", input: PDF_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf1" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
pdfModelOverride.current = pdfReaderModel();
|
||||
try {
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
pdfModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered");
|
||||
const result = done.result as PdfResult;
|
||||
assert.equal(result.path, PDF_FIXTURE);
|
||||
assert.match(result.brief, /integral rule/);
|
||||
assert.deepEqual(result.pagesConsulted, [1, 2]);
|
||||
|
||||
assert.equal(delivery.steers.length, 1, "one job, one steer");
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.equal(steer.metadata.subagentResult.kind, "pdf");
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("integral rule"), "steer text carries the brief");
|
||||
});
|
||||
|
||||
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
|
||||
const chat = chatStore.createChat("Test: pdf runner failure");
|
||||
trackChat(chat.id);
|
||||
const badPath = path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt");
|
||||
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf2" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
pdfModelOverride.current = pdfReaderModel();
|
||||
try {
|
||||
await runner.runJob(job.id, { path: badPath, question: "anything" });
|
||||
} finally {
|
||||
pdfModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "error", "the failure brief is a subagent failure");
|
||||
const result = done.result as PdfResult;
|
||||
assert.match(result.brief, /^PDF failed:/);
|
||||
assert.deepEqual(result.pagesConsulted, []);
|
||||
|
||||
const steer = delivery.steers[0]!;
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("flag the gap"), "the teacher learns to flag the gap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("researcher agent", () => {
|
||||
it("runs with the web tool set (no network touched)", () => {
|
||||
// The researcher is a runSubagent call over the webTools registry —
|
||||
@@ -568,6 +669,14 @@ describe("researcher agent", () => {
|
||||
// reset any accidental global override
|
||||
researchModelOverride.current = undefined;
|
||||
});
|
||||
|
||||
it("the pdf-reader declares exactly the four skill tools (sources v0)", async () => {
|
||||
const { createPdfTools } = await import("../src/agent/subagents/pdf-reader.ts");
|
||||
const tools = createPdfTools(path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"));
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["pdf_extract", "pdf_info", "pdf_render", "pdf_search"]);
|
||||
// reset any accidental global override
|
||||
pdfModelOverride.current = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. the wake pipeline (server-side steer delivery) ────────────────────────
|
||||
@@ -949,6 +1058,37 @@ describe("prompt composition", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("the question principles compose into the teacher prompt; the probe's quick-measurement revision landed", () => {
|
||||
const teacher = composeInstructions("teacher");
|
||||
// the T2a doctrine fragment (user-locked wording 2026-09-15): verbatim presence
|
||||
for (const pin of [
|
||||
"Answerable by reasoning from the session",
|
||||
"Never just reciting your words back",
|
||||
"Prefer problems that apply the knowledge",
|
||||
"Catch confusion early, and make it nameable",
|
||||
]) {
|
||||
assert.ok(teacher.includes(pin), `teacher prompt lost the principle: ${pin}`);
|
||||
}
|
||||
// it loads in sorted fragment order: right after 20-teaching, before 30-jobs
|
||||
assert.ok(
|
||||
teacher.indexOf("Question principles") > teacher.indexOf("# Teach — the loop"),
|
||||
"principles sit after the teaching loop",
|
||||
);
|
||||
// the prober's doctrine: the "no rush" framing is gone, quick measurement in
|
||||
const prober = composeInstructions("prober");
|
||||
assert.ok(!prober.includes("There is no rush"), "the old no-rush framing is retired");
|
||||
assert.ok(prober.includes("measurement should be efficient"), "the quick-measurement directive is in");
|
||||
assert.ok(!prober.includes("Question principles"), "the teach-phase fragment never composes into the prober");
|
||||
});
|
||||
|
||||
it("the sources-v0 prompt lines compose: read/pdf doctrine in the teacher, the read line in the prober", () => {
|
||||
const teacher = composeInstructions("teacher");
|
||||
assert.ok(teacher.includes("Learner source files"), "the teacher carries the source-files section");
|
||||
assert.ok(teacher.includes("brief with page citations"), "the pdf brief's citation duty is stated");
|
||||
const prober = composeInstructions("prober");
|
||||
assert.ok(prober.includes("the `read` tool can inspect it directly"), "the prober knows it can read learner files");
|
||||
});
|
||||
|
||||
it("guards prompt size — no fragment can silently balloon (the README-leak class)", () => {
|
||||
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
|
||||
// teacher turn until the numeric-prefix filter stopped it. This guard
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* pdf-ops (sources v0) — the pdfjs text layer against a committed fixture
|
||||
* (tests/fixtures/sample-3page.pdf: 3 pages, distinct subjects, "force" on
|
||||
* page 2 only). The render path (headless Chrome) is covered by the
|
||||
* pdf-reader loop tests via the headlessOverride fake.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { parsePageSpec, pdfExtract, pdfInfo, pdfProbe, pdfSearch } from "../src/agent/subagents/pdf-ops.ts";
|
||||
|
||||
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
|
||||
describe("pdf-ops — the pdfjs text layer", () => {
|
||||
it("pdfInfo triages every page (count, text lengths, image counts, math density)", async () => {
|
||||
const info = await pdfInfo(FIXTURE);
|
||||
assert.equal(info.page_count, 3);
|
||||
assert.equal(info.pages.length, 3);
|
||||
assert.equal(info.pages[0]!.page, 1);
|
||||
assert.ok(info.pages.every((p) => p.text_length > 0));
|
||||
assert.ok(info.mean_text_length > 0);
|
||||
// fixture has no images
|
||||
assert.ok(info.pages.every((p) => p.image_count === 0));
|
||||
});
|
||||
|
||||
it("pdfExtract heads pages and honors the spec", async () => {
|
||||
const two = await pdfExtract(FIXTURE, "1-2");
|
||||
assert.match(two, /^--- Page 1 ---/);
|
||||
assert.match(two, /--- Page 2 ---/);
|
||||
assert.ok(!two.includes("--- Page 3 ---"), "page 3 not requested");
|
||||
assert.match(two, /Calculus/);
|
||||
const picks = await pdfExtract(FIXTURE, "1,3");
|
||||
assert.match(picks, /--- Page 1 ---/);
|
||||
assert.ok(!picks.includes("--- Page 2 ---"));
|
||||
assert.match(picks, /Chemistry/);
|
||||
});
|
||||
|
||||
it("pdfSearch finds hits with page/line + context, literal and regex", async () => {
|
||||
const lit = await pdfSearch(FIXTURE, "force", { literal: true });
|
||||
assert.equal(lit.hits.length, 1);
|
||||
assert.equal(lit.hits[0]!.page, 2);
|
||||
assert.match(lit.hits[0]!.context, />>> /);
|
||||
// regex with case-insensitivity
|
||||
const re = await pdfSearch(FIXTURE, "H2O|water");
|
||||
assert.equal(re.hits.length, 1);
|
||||
assert.equal(re.hits[0]!.page, 3);
|
||||
// no-hit silence
|
||||
const none = await pdfSearch(FIXTURE, "zymurgy", { literal: true });
|
||||
assert.equal(none.hits.length, 0);
|
||||
// invalid regex throws the skill's error shape
|
||||
await assert.rejects(() => pdfSearch(FIXTURE, "([unclosed"), /Invalid regex/);
|
||||
});
|
||||
|
||||
it("pdfProbe accepts the fixture and rejects garbage", async () => {
|
||||
await pdfProbe(FIXTURE); // resolves
|
||||
const garbage = path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt");
|
||||
await assert.rejects(() => pdfProbe(garbage));
|
||||
});
|
||||
|
||||
it("parsePageSpec: all / ranges / lists / clamping", () => {
|
||||
assert.deepEqual(parsePageSpec("all", 3), [1, 2, 3]);
|
||||
assert.deepEqual(parsePageSpec("1-5", 3), [1, 2, 3]);
|
||||
assert.deepEqual(parsePageSpec("1,3,7", 3), [1, 3]);
|
||||
assert.deepEqual(parsePageSpec("3", 3), [3]);
|
||||
assert.deepEqual(parsePageSpec(" 2 , 1 ", 3), [1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The pdf-reader sub-agent (sources v0) — the hand-rolled loop on a faux
|
||||
* engine: triage→extract→brief flow, the unread-finish doctrine gate, the
|
||||
* render path via the headless fake, and the unreadable-source failure.
|
||||
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
|
||||
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
|
||||
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
|
||||
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
||||
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
|
||||
faux.setResponses(steps);
|
||||
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
||||
}
|
||||
|
||||
const PNG_1PX = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
available: () => true,
|
||||
unavailableReason: () => null,
|
||||
runDemo: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
renderSvg: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
renderPdfPages: async (_pdfPath, pages) => {
|
||||
calls.push(`render:${pages.join(",")}`);
|
||||
rendered.pages.push(...pages);
|
||||
return pages.map((page) => ({ page, png: PNG_1PX }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pdf-reader loop (faux engine)", () => {
|
||||
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
|
||||
const engine = fauxEngine([
|
||||
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage(
|
||||
[fauxToolCall("pdf_extract", { pages: "1-2" }, { id: "e1" })],
|
||||
{ stopReason: "toolUse" },
|
||||
),
|
||||
fauxAssistantMessage([fauxText("The integral rule appears on page 1 (p. 1). Pages consulted: 1, 2")]),
|
||||
]);
|
||||
const result = await runPdfReaderAgent(
|
||||
{ path: FIXTURE, question: "what is the integral of x?" },
|
||||
{ engine },
|
||||
);
|
||||
assert.equal(result.path, FIXTURE);
|
||||
assert.match(result.brief, /integral rule/);
|
||||
assert.deepEqual(result.pagesConsulted, [1, 2]);
|
||||
});
|
||||
|
||||
it("the unread-finish gate: a brief with zero consultation is bounced, not delivered", async () => {
|
||||
let nudged = false;
|
||||
const engine = fauxEngine([
|
||||
// first finish attempt with NOTHING read — the loop must inject the
|
||||
// nudge and keep going rather than deliver invention
|
||||
fauxAssistantMessage([fauxText("The answer is 42, trust me.")]),
|
||||
// after the nudge: consult, then brief honestly
|
||||
fauxAssistantMessage(
|
||||
[fauxToolCall("pdf_search", { query: "integral", literal: false }, { id: "s1" })],
|
||||
{ stopReason: "toolUse" },
|
||||
),
|
||||
fauxAssistantMessage([fauxText("Found it on page 1 (p. 1): the integral of x dx.")]),
|
||||
]);
|
||||
const result = await runPdfReaderAgent(
|
||||
{ path: FIXTURE, question: "integral of x?" },
|
||||
{ engine, onActivity: (line) => void (line && (nudged ||= line.startsWith("pdf_search"))) },
|
||||
);
|
||||
assert.match(result.brief, /Found it on page 1/);
|
||||
assert.deepEqual(result.pagesConsulted, [1]);
|
||||
// the first (invention) answer must NOT be the delivered brief
|
||||
assert.notEqual(result.brief, "The answer is 42, trust me.");
|
||||
});
|
||||
|
||||
it("the render path: images ride a user message after tool results; pages counted", async () => {
|
||||
const headless = fakeHeadless();
|
||||
const engine = fauxEngine([
|
||||
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "2-3", dpi: 150 }, { id: "r1" })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
fauxAssistantMessage([fauxText("The physics formula is visible on page 2 (p. 2).")]),
|
||||
]);
|
||||
const result = await runPdfReaderAgent(
|
||||
{ path: FIXTURE, question: "what is the force formula?" },
|
||||
{ engine, headless },
|
||||
);
|
||||
assert.deepEqual(result.pagesConsulted, [2, 3]);
|
||||
assert.deepEqual(headless.calls, ["render:2,3"]);
|
||||
});
|
||||
|
||||
it("no Chrome: render calls degrade to an error note, text tools still work", async () => {
|
||||
const engine = fauxEngine([
|
||||
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "r1" })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
// the reader adapts after the error: extract instead, then brief
|
||||
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "1" }, { id: "e1" })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
fauxAssistantMessage([fauxText("Extracted instead: the calculus line (p. 1).")]),
|
||||
]);
|
||||
const result = await runPdfReaderAgent(
|
||||
{ path: FIXTURE, question: "page 1 content?" },
|
||||
{
|
||||
engine,
|
||||
headless: {
|
||||
available: () => false,
|
||||
unavailableReason: () => "No Chrome found",
|
||||
runDemo: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
renderSvg: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
renderPdfPages: async () => {
|
||||
throw new Error("should not render");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.match(result.brief, /Extracted instead/);
|
||||
assert.deepEqual(result.pagesConsulted, [1]);
|
||||
});
|
||||
|
||||
it("an unreadable source fails deterministically before any model call", async () => {
|
||||
const engine = fauxEngine([fauxAssistantMessage([fauxText("never reached")])]);
|
||||
const result = await runPdfReaderAgent(
|
||||
{ path: path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt"), question: "anything" },
|
||||
{ engine },
|
||||
);
|
||||
// had the model been consulted, the brief would be its text instead
|
||||
assert.match(result.brief, /^PDF failed:/);
|
||||
assert.ok(!result.brief.includes("never reached"));
|
||||
assert.deepEqual(result.pagesConsulted, []);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,9 @@ import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { createProberTools, teachQuizToolFor } from "../src/agent/tools.ts";
|
||||
import { createProberTools, createTeacherTools, teachQuizToolFor, type TeacherToolContext } from "../src/agent/tools.ts";
|
||||
import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { probeQuizInputSchema } from "../src/core/question-core.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
@@ -141,11 +143,59 @@ describe("the prober's quiz: no nodeId field at all", () => {
|
||||
assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated");
|
||||
});
|
||||
|
||||
it("the prober's tool registry carries the nodeId-less quiz and probe_done only", () => {
|
||||
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the shared read tool", () => {
|
||||
const chatId = newChat("Prober: registry");
|
||||
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz"]);
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
|
||||
const schema = quizSchemaOf(tools);
|
||||
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
|
||||
});
|
||||
});
|
||||
});
|
||||
// ─── tool availability (2026-09 settings seam) ───────────────────────────────
|
||||
|
||||
describe("tool toggles (core/pending.ts + factories)", () => {
|
||||
it("TOGGLEABLE_TOOLS: the sub-agent dispatch tools; quiz/ask/read stay off the list", () => {
|
||||
// The user-toggleable vocabulary Platform's settings validates against.
|
||||
// quiz/ask_user_question/read are always-on FOR NOW (user-decided
|
||||
// 2026-09-15, sources v0); they join when settings exposes them — the
|
||||
// factory seam is generic.
|
||||
assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual", "pdf"]);
|
||||
});
|
||||
|
||||
it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => {
|
||||
const chatId = newChat("Prober: toggles");
|
||||
const ctx = { chatId, chatStore, deliver: () => {} };
|
||||
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
|
||||
|
||||
const noAsk = createProberTools(ctx, { ask_user_question: false });
|
||||
assert.deepEqual(Object.keys(noAsk).sort(), ["probe_done", "quiz", "read"]);
|
||||
|
||||
// quiz off is honored by the generic machinery too (future toggleable),
|
||||
// but probe_done is never droppable — it is how the phase ends.
|
||||
const noQuiz = createProberTools(ctx, { quiz: false });
|
||||
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "probe_done", "read"]);
|
||||
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
|
||||
});
|
||||
|
||||
it("createTeacherTools: {research: false} removes exactly research; unknown keys are no-ops", () => {
|
||||
const chatId = newChat("Teacher: toggles");
|
||||
const ctx: TeacherToolContext = {
|
||||
chatId,
|
||||
chatStore,
|
||||
jobStore,
|
||||
spawn: () => {},
|
||||
};
|
||||
const full = Object.keys(createTeacherTools(ctx)).sort();
|
||||
assert.deepEqual(full, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "research", "visual"]);
|
||||
|
||||
const noResearch = Object.keys(createTeacherTools(ctx, { research: false })).sort();
|
||||
assert.deepEqual(noResearch, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "visual"]);
|
||||
|
||||
// absent = enabled: an empty record and undefined behave identically
|
||||
assert.deepEqual(
|
||||
Object.keys(createTeacherTools(ctx, {})).sort(),
|
||||
full,
|
||||
"absent keys mean enabled — old records behave exactly as today",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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