free-recall explain tool: the quizzing-mechanic switch (owner 2026-09-27)
Sycophancy mitigation as a structural commitment: the model writes its full correctAnswer (plus explanation, in that order) BEFORE the learner sees the question; the commitment rides the persisted call args, is revealed on submit, and grades his free text in the model's next reply — the client never grades free recall. - tools.explain setting (segmented 'Quizzing — Multiple choice | Free recall' in Agent settings' Tools group): free arms explain and drops quiz for BOTH agents (prober variant carries no nodeId, same pattern as the probe quiz); mechanics keys never pass filterEnabledTools — quiz stays unwritable (the 2026-09-19 wedge ruling stands) - doctrine lives pi-natively in the tool descriptions (owner ruling — no conditional system-prompt chunks): explain carries the commitment rule; the MC option-building craft moved out of the prompt into the quiz description; shared/20-question-tools.md slims to the quiz-agnostic core - display seams: parseExplainInput (LaTeX-debris restoration included), dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard reveal (The answer / Why, no verdict chips), [Explain answer] format - phases/plan seams recognize both quizzing tools' nodeId tags; explain answers deliberately feed no ledger evidence (no client grading to key on — a future ruling can parse the model's grade) 1049/1049 tests, typecheck/lint/build green.
This commit is contained in:
@@ -11,6 +11,9 @@ import type { QuestionAnswerData } from "@/src/core";
|
|||||||
* Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the
|
* Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the
|
||||||
* explanation. Ask answers are NOT graded — a plain record of what was said.
|
* explanation. Ask answers are NOT graded — a plain record of what was said.
|
||||||
* (The v1 bug this pins: ask answers must never get quiz grading UI.)
|
* (The v1 bug this pins: ask answers must never get quiz grading UI.)
|
||||||
|
* Explain answers are graded by the MODEL in its next reply, not by the
|
||||||
|
* client — the card reveals his answer, the committed correct answer, and
|
||||||
|
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
|
||||||
*/
|
*/
|
||||||
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||||
if (answer.dismissed) {
|
if (answer.dismissed) {
|
||||||
@@ -22,13 +25,14 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isQuiz = answer.kind === "quiz";
|
const isQuiz = answer.kind === "quiz";
|
||||||
|
const isExplain = answer.kind === "explain";
|
||||||
const selectedIdx = new Set(answer.answers.map((a) => a.index));
|
const selectedIdx = new Set(answer.answers.map((a) => a.index));
|
||||||
const correctIdx = new Set(answer.correctIndices ?? []);
|
const correctIdx = new Set(answer.correctIndices ?? []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{isQuiz ? "Quiz" : "Question"}
|
{isQuiz ? "Quiz" : isExplain ? "Explain" : "Question"}
|
||||||
{isQuiz &&
|
{isQuiz &&
|
||||||
(answer.dontKnow ? (
|
(answer.dontKnow ? (
|
||||||
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
|
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
|
||||||
@@ -97,7 +101,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
|
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
|
||||||
{answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>}
|
{answer.dontKnow && (isQuiz || isExplain) && (
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{answer.note && (
|
{answer.note && (
|
||||||
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
|
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
|
||||||
@@ -119,6 +125,26 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The explain reveal (owner ruling 2026-09-27): the committed answer
|
||||||
|
and its explanation, shown on submit — no verdict, the model's next
|
||||||
|
reply IS the grading. */}
|
||||||
|
{isExplain && (answer.committed || answer.explanation) && (
|
||||||
|
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||||
|
{answer.committed && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-foreground">The answer: </span>
|
||||||
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{answer.committed && answer.explanation && <div className="my-1" />}
|
||||||
|
{answer.explanation && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-foreground">Why: </span>
|
||||||
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1115,7 +1115,8 @@ export function Chat({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "tool-quiz":
|
case "tool-quiz":
|
||||||
case "tool-ask_user_question": {
|
case "tool-ask_user_question":
|
||||||
|
case "tool-explain": {
|
||||||
// Pending/streaming questions live in the dock, not the
|
// Pending/streaming questions live in the dock, not the
|
||||||
// transcript; answered ones render as graded records.
|
// transcript; answered ones render as graded records.
|
||||||
if (part.state === "output-available") {
|
if (part.state === "output-available") {
|
||||||
@@ -1650,7 +1651,7 @@ function DockOverlay({
|
|||||||
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<QuestionDock
|
<QuestionDock
|
||||||
toolName={pending.tool as "quiz" | "ask_user_question"}
|
toolName={pending.tool as "quiz" | "ask_user_question" | "explain"}
|
||||||
toolCallId={pending.toolCallId}
|
toolCallId={pending.toolCallId}
|
||||||
input={pending.input as never}
|
input={pending.input as never}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import {
|
|||||||
shuffleOptions,
|
shuffleOptions,
|
||||||
parseAskInput,
|
parseAskInput,
|
||||||
parseQuizInput,
|
parseQuizInput,
|
||||||
|
parseExplainInput,
|
||||||
toggleOption,
|
toggleOption,
|
||||||
toggleOther,
|
toggleOther,
|
||||||
type AnswerRequest,
|
type AnswerRequest,
|
||||||
type AskToolInput,
|
type AskToolInput,
|
||||||
type DockSelection,
|
type DockSelection,
|
||||||
|
type ExplainToolInput,
|
||||||
type QuestionAnswerData,
|
type QuestionAnswerData,
|
||||||
type QuestionData,
|
type QuestionData,
|
||||||
type QuestionMode,
|
type QuestionMode,
|
||||||
@@ -50,9 +52,9 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface QuestionDockProps {
|
export interface QuestionDockProps {
|
||||||
toolName: "quiz" | "ask_user_question";
|
toolName: "quiz" | "ask_user_question" | "explain";
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
input: QuizToolInput | AskToolInput;
|
input: QuizToolInput | AskToolInput | ExplainToolInput;
|
||||||
/** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */
|
/** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -92,7 +94,8 @@ export function QuestionDock({
|
|||||||
// bridge), so this is pure defense in depth — but it is STRUCTURAL now:
|
// bridge), so this is pure defense in depth — but it is STRUCTURAL now:
|
||||||
// anything that slips through renders the quiet fallback, never a
|
// anything that slips through renders the quiet fallback, never a
|
||||||
// crash on `.map` over model-authored bytes.
|
// crash on `.map` over model-authored bytes.
|
||||||
const parsed = toolName === "quiz" ? parseQuizInput(input) : parseAskInput(input);
|
const parsed =
|
||||||
|
toolName === "quiz" ? parseQuizInput(input) : toolName === "explain" ? parseExplainInput(input) : parseAskInput(input);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return (
|
return (
|
||||||
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
|
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
|
||||||
@@ -148,7 +151,7 @@ export function QuestionDockBar({
|
|||||||
onExpand,
|
onExpand,
|
||||||
variant = "collapsed",
|
variant = "collapsed",
|
||||||
}: {
|
}: {
|
||||||
toolName: "quiz" | "ask_user_question";
|
toolName: "quiz" | "ask_user_question" | "explain";
|
||||||
question: string;
|
question: string;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
variant?: "collapsed" | "reveal";
|
variant?: "collapsed" | "reveal";
|
||||||
@@ -182,7 +185,7 @@ export function QuestionDockBar({
|
|||||||
className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40"
|
className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40"
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{toolName === "quiz" ? "Quiz" : "Question"}
|
{toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question"}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
|
||||||
<span className="flex shrink-0 items-center gap-1 text-xs font-medium">
|
<span className="flex shrink-0 items-center gap-1 text-xs font-medium">
|
||||||
@@ -202,7 +205,7 @@ function QuestionDockForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onEscape,
|
onEscape,
|
||||||
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
||||||
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
|
const kind = toolName === "quiz" ? ("quiz" as const) : toolName === "explain" ? ("explain" as const) : ("ask" as const);
|
||||||
|
|
||||||
// Canonical QuestionData, built once per question. Quiz options are
|
// Canonical QuestionData, built once per question. Quiz options are
|
||||||
// deterministically shuffled (seeded by toolCallId) so position never leaks
|
// deterministically shuffled (seeded by toolCallId) so position never leaks
|
||||||
@@ -211,7 +214,8 @@ function QuestionDockForm({
|
|||||||
const qd = useMemo<QuestionData>(() => {
|
const qd = useMemo<QuestionData>(() => {
|
||||||
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
|
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
|
||||||
const askInput = kind === "ask" ? (input as AskToolInput) : null;
|
const askInput = kind === "ask" ? (input as AskToolInput) : null;
|
||||||
const multi = input.multiSelect === true;
|
const explainInput = kind === "explain" ? (input as ExplainToolInput) : null;
|
||||||
|
const multi = input instanceof Object && "multiSelect" in input ? input.multiSelect === true : false;
|
||||||
const mode: QuestionMode =
|
const mode: QuestionMode =
|
||||||
kind === "quiz"
|
kind === "quiz"
|
||||||
? multi
|
? multi
|
||||||
@@ -241,6 +245,12 @@ function QuestionDockForm({
|
|||||||
q.correctIndices = resolved.indices;
|
q.correctIndices = resolved.indices;
|
||||||
q.explanation = quizInput.explanation;
|
q.explanation = quizInput.explanation;
|
||||||
}
|
}
|
||||||
|
if (explainInput) {
|
||||||
|
// The commitment rides the question data (never sent to the
|
||||||
|
// client before answering) and snapshots into the answer at submit.
|
||||||
|
q.committedAnswer = explainInput.correctAnswer;
|
||||||
|
q.explanation = explainInput.explanation;
|
||||||
|
}
|
||||||
return q;
|
return q;
|
||||||
}, [toolCallId, kind, input]);
|
}, [toolCallId, kind, input]);
|
||||||
|
|
||||||
@@ -275,7 +285,7 @@ function QuestionDockForm({
|
|||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
qd.mode === "text"
|
qd.mode === "text"
|
||||||
? text.trim().length > 0
|
? sel.dontKnow || text.trim().length > 0 // explain: dontKnow is a first-class answer
|
||||||
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
|
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -314,13 +324,14 @@ function QuestionDockForm({
|
|||||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||||
const thinkingRef = useRef<HTMLTextAreaElement>(null);
|
const thinkingRef = useRef<HTMLTextAreaElement>(null);
|
||||||
// One row past the options is the trailing row: quiz → don't-know,
|
// One row past the options is the trailing row: quiz → don't-know,
|
||||||
// ask → Other (a question never has both).
|
// ask → Other (a question never has both). Explain has exactly ONE
|
||||||
|
// row — its don't-know (there are no options to walk).
|
||||||
const optionCount = qd.mode === "text" ? 0 : qd.options.length;
|
const optionCount = qd.mode === "text" ? 0 : qd.options.length;
|
||||||
const rowCount = qd.mode === "text" ? 0 : optionCount + 1;
|
const rowCount = qd.mode === "text" ? (kind === "explain" ? 1 : 0) : optionCount + 1;
|
||||||
|
|
||||||
const kbdAct = (idx: number) => {
|
const kbdAct = (idx: number) => {
|
||||||
if (idx < optionCount) toggle(idx);
|
if (idx < optionCount) toggle(idx);
|
||||||
else if (kind === "quiz")
|
else if (kind === "quiz" || kind === "explain")
|
||||||
setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow }));
|
setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow }));
|
||||||
else {
|
else {
|
||||||
// Space on the ask's Other row (owner refinement round,
|
// Space on the ask's Other row (owner refinement round,
|
||||||
@@ -412,7 +423,7 @@ function QuestionDockForm({
|
|||||||
{/* Question area — scrolls internally if very tall; the bottom row stays put. */}
|
{/* Question area — scrolls internally if very tall; the bottom row stays put. */}
|
||||||
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3">
|
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{kind === "quiz" ? "Quiz" : "Question"}
|
{kind === "quiz" ? "Quiz" : kind === "explain" ? "Explain" : "Question"}
|
||||||
{qd.mode === "multi-select" && " · select all that apply"}
|
{qd.mode === "multi-select" && " · select all that apply"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 font-medium">
|
<div className="mb-3 font-medium">
|
||||||
@@ -424,7 +435,10 @@ function QuestionDockForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{qd.mode !== "text" && (
|
{/* Text-mode questions render no options list — EXCEPT explain,
|
||||||
|
whose single "I don't know" row is the one walkable answer
|
||||||
|
(kbd row 0). The answer itself goes in the composer below. */}
|
||||||
|
{(qd.mode !== "text" || kind === "explain") && (
|
||||||
<div className="flex flex-col gap-2 pb-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
<div className="flex flex-col gap-2 pb-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
||||||
{qd.options.map((opt, i) => {
|
{qd.options.map((opt, i) => {
|
||||||
const isSel = sel.selected.has(i);
|
const isSel = sel.selected.has(i);
|
||||||
@@ -470,7 +484,7 @@ function QuestionDockForm({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{kind === "quiz" && (
|
{(kind === "quiz" || kind === "explain") && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||||
|
|
||||||
@@ -110,6 +111,15 @@ export function composeTool(agent: AgentSettings | undefined, id: string, on: bo
|
|||||||
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The quizzing mechanics (owner 2026-09-27): a segmented control, not a
|
||||||
|
* toggle — exactly one mechanic is ever active. `explain: true` arms
|
||||||
|
* free-recall (explain in, quiz out); anything else is multiple choice.
|
||||||
|
* `quiz` itself is never written (mechanics-managed — see quizMechanics
|
||||||
|
* in agent/tools.ts). Pure — exported for tests. */
|
||||||
|
export function composeQuizMechanics(agent: AgentSettings | undefined, mechanics: "choice" | "free"): AgentSettings {
|
||||||
|
return { ...agent, tools: { ...agent?.tools, explain: mechanics === "free" } };
|
||||||
|
}
|
||||||
|
|
||||||
/** Compose the prober graph-tools toggle (wave 7): its own key in the
|
/** Compose the prober graph-tools toggle (wave 7): its own key in the
|
||||||
* agent section — never in the tool-toggles map (owner ruling). Absent =
|
* agent section — never in the tool-toggles map (owner ruling). Absent =
|
||||||
* ON (the toggle discipline); the write is explicit either way, so an
|
* ON (the toggle discipline); the write is explicit either way, so an
|
||||||
@@ -725,6 +735,46 @@ export function AgentSection({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{/* The quizzing mechanic (owner 2026-09-27): segmented — exactly one
|
||||||
|
of Multiple choice / Free recall is active; the choice swaps which
|
||||||
|
quizzing tool BOTH agents carry (prober checks + teach checks).
|
||||||
|
Persisted as agent.tools.explain; absent = Multiple choice. */}
|
||||||
|
{(() => {
|
||||||
|
const free = agent?.tools?.explain === true;
|
||||||
|
return (
|
||||||
|
<SettingsRow
|
||||||
|
label="Quizzing"
|
||||||
|
hint="How both tutors check your understanding"
|
||||||
|
testid="quiz-mechanics-row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Quizzing mechanics"
|
||||||
|
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||||
|
>
|
||||||
|
{(["choice", "free"] as const).map((m) => {
|
||||||
|
const active = m === "free" ? free : !free;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
data-testid={`quiz-mechanics-${m}`}
|
||||||
|
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
|
||||||
|
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m === "choice" ? "Multiple choice" : "Free recall"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{TOGGLE_TOOLS.map((id) => {
|
{TOGGLE_TOOLS.map((id) => {
|
||||||
// Research needs a live search engine; the row + toggle gray
|
// Research needs a live search engine; the row + toggle gray
|
||||||
// on the same signal the registry drops the tool on (see
|
// on the same signal the registry drops the tool on (see
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# The probe
|
# The probe
|
||||||
|
|
||||||
Your whole job is two measurements: **what he needs to learn** (his goal — `ask_user_question`) and **what he already knows** (a detailed map of his exact understanding — `quiz`). The whole lesson will be planned from your map — it must be precise.
|
Your whole job is two measurements: **what he needs to learn** (his goal — `ask_user_question`) and **what he already knows** (a detailed map of his exact understanding — the quizzing tool: `quiz` or `explain`, whichever the learner’s mechanic armed). The whole lesson will be planned from your map — it must be precise.
|
||||||
|
|
||||||
Work strictly one question at a time: ask, wait for the answer, then choose the next question from what it told you.
|
Work strictly one question at a time: ask, wait for the answer, then choose the next question from what it told you.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Work strictly one question at a time: ask, wait for the answer, then choose the
|
|||||||
|
|
||||||
From the goal, what will the lesson depend on? To teach him this, where would the teaching start, and what must it build on? Each dependency is a strand — bound by relevance, nothing more.
|
From the goal, what will the lesson depend on? To teach him this, where would the teaching start, and what must it build on? Each dependency is a strand — bound by relevance, nothing more.
|
||||||
|
|
||||||
## 4. Binary-search every strand — `quiz`
|
## 4. Binary-search every strand — the quizzing tool
|
||||||
|
|
||||||
The edge of a strand is found by bracketing: something he reliably gets **right** (a floor) and something he gets **wrong** or doesn't know (a ceiling). Narrow the bracket one question at a time:
|
The edge of a strand is found by bracketing: something he reliably gets **right** (a floor) and something he gets **wrong** or doesn't know (a ceiling). Narrow the bracket one question at a time:
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,14 @@
|
|||||||
## The question tools
|
## The question tools
|
||||||
|
|
||||||
Two instruments. `quiz` only for questions with a known correct answer, `ask_user_question` for everything else.
|
Two instruments. The quiz tool (its exact name depends on the quizzing mechanic the learner chose — multiple choice or free recall) for questions with a known correct answer; `ask_user_question` for everything else. Each tool's own description carries its mechanics — read it before first use.
|
||||||
|
|
||||||
### `quiz`
|
### Quiz checks — shared doctrine
|
||||||
|
|
||||||
Asks a question, grades instantly, shows ✓/✗ + the correct answer + explanation to both of you. Single- or multi-select.
|
- `explanation` (required) says why the correct answer is correct. It comes BEFORE `correctAnswer` in the call by design: reason it out first, then commit to the answer the reasoning supports.
|
||||||
|
- An "I don't know" choice is always available to the learner — never author your own opt-out. A dontKnow result is a genuine gap, not a wrong answer.
|
||||||
- `correctAnswer` (required) is the option's `value` string — self-checking, never a position number. Multi-select: array of values, graded as an exact-set match.
|
- Answers may carry a free-text `note` — read it; it says what he was thinking.
|
||||||
- `explanation` (required) says why the correct answer is correct. It comes BEFORE `correctAnswer` in the call by design: reason it out first, then commit the answer the reasoning supports.
|
- ONE question per turn: ask, then end your turn — his answer determines the next.
|
||||||
- An "I don't know" option is added automatically — provide only the real, gradable options (≥2), never your own opt-out. A dontKnow result is a genuine gap, not a wrong answer.
|
|
||||||
- Answers may carry a free-text `note` from the always-present note field — read it; it says what he was thinking.
|
|
||||||
- Options display shuffled by default; `shuffle: false` only when order is meaningful (e.g. an 'All/None of the above' that must stay last). `multiSelect: true` only when several options are correct.
|
|
||||||
|
|
||||||
### `ask_user_question`
|
### `ask_user_question`
|
||||||
|
|
||||||
Asks a single question and waits. He can always pick "Other" and type. If you recommend an option, list it first with "(Recommended)" appended. Multiple questions → multiple calls.
|
Asks a single question and waits. He can always pick "Other" and type. If you recommend an option, list it first with "(Recommended)" appended. Multiple questions → multiple calls.
|
||||||
|
|
||||||
### Building quiz options
|
|
||||||
|
|
||||||
The giveaway is never the distractors' wrongness — it's asymmetry: the correct option carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in, don't audit it in afterwards:
|
|
||||||
|
|
||||||
1. Every option is a bare claim — zero "why" in any of them; all reasoning goes in `explanation`, shown only after he answers.
|
|
||||||
2. Write the correct claim first, then mutate it into each distractor: what would someone holding a specific misconception — or confusing it with a specific neighbour — claim? Same skeleton, same grain, same register: parallelism by construction.
|
|
||||||
3. Each distractor is a real error he might actually make (so his pick is diagnostic) yet unambiguously wrong.
|
|
||||||
4. No asymmetric bolding — bold the parallel term in every option or in none.
|
|
||||||
|
|
||||||
If you can tell the right one cold without knowing the material, regenerate — don't patch.
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ You write the map with `plan_init` (a mermaid `graph TD` block). It is a **map,
|
|||||||
|
|
||||||
The map is yours to rewrite at your own initiative: re-call `plan_init` whenever it's wrong — a detour node for a question worth exploring, a repair node for a caught misconception, prune what proves unnecessary. A map that can't change is a wrong map.
|
The map is yours to rewrite at your own initiative: re-call `plan_init` whenever it's wrong — a detour node for a question worth exploring, a repair node for a caught misconception, prune what proves unnecessary. A map that can't change is a wrong map.
|
||||||
|
|
||||||
**Every quiz carries `nodeId`** (required — a quiz without it is rejected): that's what moves the map. Its live position is simply the latest tagged node, so asking about the next node IS how the lesson gets there. A tag is a position, not a verdict — nodes are never marked finished. A node takes as many questions as it deserves: stay on one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward — and move on by asking about the next one. Off-map checks (no plan node fits) use ask_user_question.
|
**Every quizzing call carries `nodeId`** (required — the quizzing tool is one of `quiz` or `explain` depending on the mechanic the learner chose; a call without it is rejected): that's what moves the map. Its live position is simply the latest tagged node, so asking about the next node IS how the lesson gets there. A tag is a position, not a verdict — nodes are never marked finished. A node takes as many questions as it deserves: stay on one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward — and move on by asking about the next one. Off-map checks (no plan node fits) use ask_user_question.
|
||||||
|
|
||||||
## Presenting the plan — always, before any teaching
|
## Presenting the plan — always, before any teaching
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ Every check exists to answer one question: did the idea actually **take** — no
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Catch confusion early, and make it nameable.** Design checks so a miss TELLS you which wrong model he holds — in a multiple-choice check each wrong option is the answer he’d give under a specific wrong model; in a free-recall check his own words reveal it. 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.
|
- **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.
|
||||||
@@ -203,7 +203,8 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PROBER (agent A): quiz (no nodeId) + ask + probe_done, lean on
|
* The PROBER (agent A): the quizzing tool (quiz, or explain under free
|
||||||
|
* recall — no nodeId either way) + ask + probe_done, lean on
|
||||||
* probing doctrine. Its whole job is bracketing the learner's edge and
|
* probing doctrine. Its whole job is bracketing the learner's edge and
|
||||||
* concluding with the assessment (probe_done) — which also queues the
|
* concluding with the assessment (probe_done) — which also queues the
|
||||||
* handoff wake (see tools/probe.ts) and is the transcript seam the
|
* handoff wake (see tools/probe.ts) and is the transcript seam the
|
||||||
|
|||||||
+114
-6
@@ -4,6 +4,9 @@ import { questionResult, serverExecute, ToolInputError } from "./tool-execute.ts
|
|||||||
import {
|
import {
|
||||||
askInputSchema,
|
askInputSchema,
|
||||||
askOptionProblems,
|
askOptionProblems,
|
||||||
|
explainInputBase,
|
||||||
|
explainInputObject,
|
||||||
|
explainInputProblems,
|
||||||
parseOptionsIfString,
|
parseOptionsIfString,
|
||||||
probeQuizInputSchema,
|
probeQuizInputSchema,
|
||||||
quizInputObject,
|
quizInputObject,
|
||||||
@@ -92,6 +95,32 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
|
|||||||
|
|
||||||
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
|
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
|
||||||
|
|
||||||
|
/** The option-building craft (owner 2026-09-27: moved OUT of the system
|
||||||
|
* prompt into the tool description — pi-native doctrine travels with
|
||||||
|
* the armed tool). Shared verbatim by both quiz variants. */
|
||||||
|
const QUIZ_OPTION_CRAFT =
|
||||||
|
"Building options — the giveaway is never the distractors' wrongness, it's asymmetry: the correct option " +
|
||||||
|
"carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: " +
|
||||||
|
"(1) every option is a bare claim — zero 'why' in any of them; all reasoning goes in explanation, shown only " +
|
||||||
|
"after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone " +
|
||||||
|
"holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real " +
|
||||||
|
"error he might actually make (his pick is diagnostic) yet unambiguously wrong; (4) no asymmetric bolding. If " +
|
||||||
|
"you can tell the right one cold without knowing the material, regenerate — don't patch.";
|
||||||
|
|
||||||
|
/** The explain doctrine (owner 2026-09-27): free-recall quizzing — the
|
||||||
|
* sycophancy mitigation is the commitment. Carried by the DESCRIPTION
|
||||||
|
* (pi-native ruling), not the system prompt. Shared by both variants;
|
||||||
|
* the teach variant appends the nodeId tagging paragraph. */
|
||||||
|
const EXPLAIN_DOCTRINE =
|
||||||
|
"Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " +
|
||||||
|
"answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: write " +
|
||||||
|
"explanation (your reasoning) first, then correctAnswer — the FULL correct answer, committed before he ever sees " +
|
||||||
|
"the question. When his answer returns, grade it strictly against your committed answer in your reply: never " +
|
||||||
|
"revise the commitment after seeing his words, and never mark a wrong answer right to be kind — a near-miss is " +
|
||||||
|
"partial understanding; name exactly what he got and what he missed. He sees the committed answer and the " +
|
||||||
|
"explanation on submit. An 'I don't know' choice is always available to him — a genuine gap, not a wrong answer. " +
|
||||||
|
"ONE question per turn: call a single explain, then end your turn — his answer determines the next.";
|
||||||
|
|
||||||
export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
|
export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
|
||||||
name: "quiz",
|
name: "quiz",
|
||||||
label: "quiz",
|
label: "quiz",
|
||||||
@@ -105,7 +134,7 @@ export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
|
|||||||
"signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " +
|
"signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " +
|
||||||
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
|
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
|
||||||
"questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " +
|
"questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " +
|
||||||
"his answer determines the next question to ask.",
|
"his answer determines the next question to ask. " + QUIZ_OPTION_CRAFT,
|
||||||
parameters: probeQuizInputSchema,
|
parameters: probeQuizInputSchema,
|
||||||
prepareArguments: prepareQuestionArguments,
|
prepareArguments: prepareQuestionArguments,
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
@@ -116,6 +145,48 @@ export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The teacher's free-recall variant (nodeId-validated, same seam as the
|
||||||
|
* teach quiz — phases/plan recognize BOTH tool names). */
|
||||||
|
export function teachExplainToolFor(
|
||||||
|
ctx: Pick<PlanToolContext, "chatId" | "chatStore">,
|
||||||
|
): AgentTool<typeof explainInputObject> {
|
||||||
|
return {
|
||||||
|
name: "explain",
|
||||||
|
label: "explain",
|
||||||
|
description:
|
||||||
|
EXPLAIN_DOCTRINE +
|
||||||
|
" Set `nodeId` (required) to the plan node this question is about — the lesson's position on the map derives " +
|
||||||
|
"from those tags; a node takes as many questions as it deserves, so don't rush forward. Off-map checks use " +
|
||||||
|
"ask_user_question.",
|
||||||
|
parameters: explainInputObject,
|
||||||
|
prepareArguments: prepareQuestionArguments,
|
||||||
|
executionMode: "sequential",
|
||||||
|
execute: async (_toolCallId, input) => {
|
||||||
|
const problems = [...explainInputProblems(input)];
|
||||||
|
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
|
||||||
|
if (nodeIdProblemText) problems.push(nodeIdProblemText);
|
||||||
|
if (problems.length > 0) throw new ToolInputError("explain", problems);
|
||||||
|
return questionResult();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prober's free-recall variant (no nodeId — same pattern as the
|
||||||
|
* probe quiz). */
|
||||||
|
export const probeExplainTool: AgentTool<typeof explainInputBase> = {
|
||||||
|
name: "explain",
|
||||||
|
label: "explain",
|
||||||
|
description: EXPLAIN_DOCTRINE,
|
||||||
|
parameters: explainInputBase,
|
||||||
|
prepareArguments: prepareQuestionArguments,
|
||||||
|
executionMode: "sequential",
|
||||||
|
execute: async (_toolCallId, input) => {
|
||||||
|
const problems = explainInputProblems(input);
|
||||||
|
if (problems.length > 0) throw new ToolInputError("explain", problems);
|
||||||
|
return questionResult();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** The prober's registry context: the probe's own needs (probe_done —
|
/** The prober's registry context: the probe's own needs (probe_done —
|
||||||
* ProbeDoneContext) PLUS the job-dispatch wiring, since the owner ruling
|
* ProbeDoneContext) PLUS the job-dispatch wiring, since the owner ruling
|
||||||
* (2026-09-20) puts research + pdf in the probe phase's registry too. */
|
* (2026-09-20) puts research + pdf in the probe phase's registry too. */
|
||||||
@@ -156,10 +227,20 @@ export function createProberTools(
|
|||||||
spawn: ctx.spawn,
|
spawn: ctx.spawn,
|
||||||
searchAvailable: ctx.searchAvailable,
|
searchAvailable: ctx.searchAvailable,
|
||||||
});
|
});
|
||||||
|
// Quizzing mechanics (owner 2026-09-27): free-recall swaps the probe
|
||||||
|
// quiz for the probe explain — one quizzing tool at a time, whichever
|
||||||
|
// the settings arm. ask stays always-on.
|
||||||
|
const mechanics = quizMechanics(enabled);
|
||||||
|
// The armed quizzing tool, widened to the registry's heterogeneous
|
||||||
|
// AgentTool record BEFORE the filter (a conditional spread of two
|
||||||
|
// differently-typed literals makes the whole object's type a union —
|
||||||
|
// unusable as Record<string, AgentTool<any>> downstream).
|
||||||
|
const quizzing: Record<string, AgentTool<any>> =
|
||||||
|
mechanics === "free" ? { explain: probeExplainTool } : { quiz: probeQuizTool };
|
||||||
return {
|
return {
|
||||||
...filterEnabledTools(
|
...filterEnabledTools(
|
||||||
{
|
{
|
||||||
quiz: probeQuizTool,
|
...quizzing,
|
||||||
ask_user_question: askUserQuestionTool,
|
ask_user_question: askUserQuestionTool,
|
||||||
// The learner-filesystem set is always-on (user-decided 2026-09,
|
// The learner-filesystem set is always-on (user-decided 2026-09,
|
||||||
// sources v1): the prober inspects and LOCATES learner-referenced
|
// sources v1): the prober inspects and LOCATES learner-referenced
|
||||||
@@ -184,7 +265,7 @@ export function createProberTools(
|
|||||||
: {}),
|
: {}),
|
||||||
...done,
|
...done,
|
||||||
},
|
},
|
||||||
enabled,
|
withoutMechanics(enabled),
|
||||||
),
|
),
|
||||||
...done,
|
...done,
|
||||||
};
|
};
|
||||||
@@ -230,7 +311,8 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
|
|||||||
"plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
|
"plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
|
||||||
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
|
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
|
||||||
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
|
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
|
||||||
"ONE question per turn: call a single quiz, then end your turn — his answer determines the next.",
|
"ONE question per turn: call a single quiz, then end your turn — his answer determines the next. " +
|
||||||
|
QUIZ_OPTION_CRAFT,
|
||||||
parameters: quizInputObject,
|
parameters: quizInputObject,
|
||||||
prepareArguments: prepareQuestionArguments,
|
prepareArguments: prepareQuestionArguments,
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
@@ -269,13 +351,39 @@ export interface TeacherToolContext extends PlanToolContext {
|
|||||||
* future toggleables (question-tool variants, …) need no change here.
|
* future toggleables (question-tool variants, …) need no change here.
|
||||||
* Absent/undefined = enabled — tests and old records see today's set.
|
* Absent/undefined = enabled — tests and old records see today's set.
|
||||||
*/
|
*/
|
||||||
|
/** The quizzing mechanics (owner 2026-09-27): `tools.explain === true` in
|
||||||
|
* the resolved settings ⇒ free-recall checks (explain armed, quiz dropped)
|
||||||
|
* — anything else ⇒ multiple choice, exactly today's set. Mechanics-
|
||||||
|
* managed, NOT individually toggleable: quiz/explain never pass through
|
||||||
|
* filterEnabledTools (the 2026-09-19 wedge ruling stands — a quiz-less
|
||||||
|
* prober collapses its assessment flow). */
|
||||||
|
export function quizMechanics(enabled?: EnabledTools): "choice" | "free" {
|
||||||
|
return enabled?.explain === true ? "free" : "choice";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip the mechanics keys so filterEnabledTools can never drop the
|
||||||
|
* quizzing tools (the derivation above is their only switch). */
|
||||||
|
function withoutMechanics(enabled?: EnabledTools): EnabledTools | undefined {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
const { explain: _e, quiz: _q, ...rest } = enabled;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTeacherTools(
|
export function createTeacherTools(
|
||||||
ctx: TeacherToolContext,
|
ctx: TeacherToolContext,
|
||||||
enabled?: EnabledTools,
|
enabled?: EnabledTools,
|
||||||
): Record<string, AgentTool<any>> {
|
): Record<string, AgentTool<any>> {
|
||||||
|
// Quizzing mechanics (owner 2026-09-27): free-recall swaps the teach
|
||||||
|
// quiz for the teach explain — one quizzing tool at a time, whichever
|
||||||
|
// the settings arm. ask stays always-on.
|
||||||
|
const mechanics = quizMechanics(enabled);
|
||||||
|
// Same widening as the prober's (see above) — the conditional spread of
|
||||||
|
// two differently-typed literals collapses into an unusable union type.
|
||||||
|
const quizzing: Record<string, AgentTool<any>> =
|
||||||
|
mechanics === "free" ? { explain: teachExplainToolFor(ctx) } : { quiz: teachQuizToolFor(ctx) };
|
||||||
return filterEnabledTools(
|
return filterEnabledTools(
|
||||||
{
|
{
|
||||||
quiz: teachQuizToolFor(ctx),
|
...quizzing,
|
||||||
ask_user_question: askUserQuestionTool,
|
ask_user_question: askUserQuestionTool,
|
||||||
// The learner-filesystem set is always-on (user-decided 2026-09,
|
// The learner-filesystem set is always-on (user-decided 2026-09,
|
||||||
// sources v1): the teacher locates and reads learner-referenced
|
// sources v1): the teacher locates and reads learner-referenced
|
||||||
@@ -292,6 +400,6 @@ export function createTeacherTools(
|
|||||||
// reaches jobTools' research gate.
|
// reaches jobTools' research gate.
|
||||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
|
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
|
||||||
},
|
},
|
||||||
enabled,
|
withoutMechanics(enabled),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarri
|
|||||||
// duplicates collapsed (several checks on one node = one stay on it)
|
// duplicates collapsed (several checks on one node = one stay on it)
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
for (const part of m.parts) {
|
for (const part of m.parts) {
|
||||||
if (part.type !== "tool-quiz") continue;
|
// Either quizzing tool (quiz/explain) tags position — the mechanic
|
||||||
|
// is a settings switch; the fold recognizes both (owner 2026-09-27).
|
||||||
|
if (part.type !== "tool-quiz" && part.type !== "tool-explain") continue;
|
||||||
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
|
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
|
||||||
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing
|
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing
|
||||||
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
||||||
@@ -130,7 +132,7 @@ export function derivePlanStatusesLh(tree: PlanTree, messages: readonly LhMessag
|
|||||||
const content = (m as { content?: unknown }).content;
|
const content = (m as { content?: unknown }).content;
|
||||||
if (!Array.isArray(content)) continue;
|
if (!Array.isArray(content)) continue;
|
||||||
for (const block of content as Array<{ type?: string; name?: string; arguments?: unknown }>) {
|
for (const block of content as Array<{ type?: string; name?: string; arguments?: unknown }>) {
|
||||||
if (block.type !== "toolCall" || block.name !== "quiz") continue;
|
if (block.type !== "toolCall" || (block.name !== "quiz" && block.name !== "explain")) continue;
|
||||||
const nodeId = (block.arguments as { nodeId?: unknown } | undefined)?.nodeId;
|
const nodeId = (block.arguments as { nodeId?: unknown } | undefined)?.nodeId;
|
||||||
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue;
|
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue;
|
||||||
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
||||||
|
|||||||
+8
-1
@@ -39,7 +39,7 @@ export interface PendingToolCall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Tools that render in the question dock and gate the composer. */
|
/** Tools that render in the question dock and gate the composer. */
|
||||||
export const QUESTION_TOOLS = ["quiz", "ask_user_question"] as const;
|
export const QUESTION_TOOLS = ["quiz", "ask_user_question", "explain"] as const;
|
||||||
|
|
||||||
/** Tools fulfilled by background sub-agent jobs (W2/W6). */
|
/** Tools fulfilled by background sub-agent jobs (W2/W6). */
|
||||||
export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
||||||
@@ -62,6 +62,13 @@ export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
|||||||
* 2026-09). */
|
* 2026-09). */
|
||||||
export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
||||||
|
|
||||||
|
/** The keys the settings WRITE side accepts in agent.tools: the plain
|
||||||
|
* toggleables PLUS the quizzing-mechanics switch (`explain: true` arms
|
||||||
|
* free-recall quizzing and drops the MC quiz — see quizMechanics in
|
||||||
|
* agent/tools.ts; owner 2026-09-27). `quiz` itself stays unwritable:
|
||||||
|
* mechanics-managed, never individually toggleable. */
|
||||||
|
export const WRITABLE_TOOL_KEYS = [...TOGGLEABLE_TOOLS, "explain"] as const;
|
||||||
|
|
||||||
/** The resolved settings → factory seam shape (Platform wires chat-handler
|
/** The resolved settings → factory seam shape (Platform wires chat-handler
|
||||||
* to pass this through from the settings record; storage and defaults are
|
* to pass this through from the settings record; storage and defaults are
|
||||||
* Platform's lane). JSON-native: a partial record keyed by tool name.
|
* Platform's lane). JSON-native: a partial record keyed by tool name.
|
||||||
|
|||||||
+8
-2
@@ -83,7 +83,13 @@ function advance(
|
|||||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||||
phase = "plan";
|
phase = "plan";
|
||||||
}
|
}
|
||||||
if (phase === "plan" && part.type === "tool-quiz" && taggedNodeId(part.input)) {
|
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
|
||||||
|
// explain, whichever mechanic the settings armed (both carry nodeId).
|
||||||
|
if (
|
||||||
|
phase === "plan" &&
|
||||||
|
(part.type === "tool-quiz" || part.type === "tool-explain") &&
|
||||||
|
taggedNodeId(part.input)
|
||||||
|
) {
|
||||||
phase = "teach";
|
phase = "teach";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +142,7 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
|
|||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (
|
if (
|
||||||
block.type === "toolCall" &&
|
block.type === "toolCall" &&
|
||||||
block.name === "quiz" &&
|
(block.name === "quiz" || block.name === "explain") &&
|
||||||
taggedNodeId(block.arguments)
|
taggedNodeId(block.arguments)
|
||||||
) {
|
) {
|
||||||
phase = "teach";
|
phase = "teach";
|
||||||
|
|||||||
@@ -217,6 +217,68 @@ export const quizInputObject = Type.Object({
|
|||||||
* it ran at the call boundary, where quizInputProblems runs now). */
|
* it ran at the call boundary, where quizInputProblems runs now). */
|
||||||
export const probeQuizInputSchema = quizInputBase;
|
export const probeQuizInputSchema = quizInputBase;
|
||||||
|
|
||||||
|
// ─── the explain tool (free-recall quizzing, owner 2026-09-27) ─────────────
|
||||||
|
//
|
||||||
|
// Quiz mechanics WITHOUT options: the learner answers in his own words.
|
||||||
|
// The SYCOPHANCY MITIGATION is structural — the model commits to the full
|
||||||
|
// correct answer BEFORE posing (the commitment rides the persisted call
|
||||||
|
// args, is revealed to the learner on submit, and is restated in the answer
|
||||||
|
// result the model grades against; the client never grades free text).
|
||||||
|
// Mechanics doctrine lives in the TOOL DESCRIPTIONS (owner ruling
|
||||||
|
// 2026-09-27: pi-native — doctrine travels with the armed tool), not in
|
||||||
|
// the system prompt.
|
||||||
|
|
||||||
|
export const explainInputBase = Type.Object({
|
||||||
|
question: Type.String({
|
||||||
|
description:
|
||||||
|
"The single question to ask. ONE question per turn: ask, then end your turn — the answer determines the next.",
|
||||||
|
}),
|
||||||
|
explanation: Type.String({
|
||||||
|
description:
|
||||||
|
"REQUIRED. Explanation revealed AFTER the learner answers (shown however he did). It is written BEFORE " +
|
||||||
|
"correctAnswer by design: reason it out first, then commit to the answer the reasoning supports.",
|
||||||
|
}),
|
||||||
|
correctAnswer: Type.String({
|
||||||
|
description:
|
||||||
|
"REQUIRED. The full correct answer in your own words — COMMITTED before the learner ever sees the question. " +
|
||||||
|
"The commitment is immutable: when his answer returns, grade it strictly against this text — never revise the " +
|
||||||
|
"commitment after seeing his words, and never mark a wrong answer right to be kind. Both this and the explanation " +
|
||||||
|
"are shown to him on submit; your next reply grades the nuance of what he wrote.",
|
||||||
|
}),
|
||||||
|
details: Type.Optional(
|
||||||
|
Type.String({ description: "Optional extra context or instructions shown under the question." }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The TEACH-side explain object: base + the plan-position field (same
|
||||||
|
* semantics as the quiz's nodeId — the seam recognizes BOTH tools). */
|
||||||
|
export const explainInputObject = Type.Object({
|
||||||
|
...explainInputBase.properties,
|
||||||
|
nodeId: Type.String({
|
||||||
|
description:
|
||||||
|
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
|
||||||
|
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
|
||||||
|
"The lesson's position on the map derives from these tags (the node the latest tagged question is about is " +
|
||||||
|
"where the map stands; earlier ones count as visited). A node takes as many questions as it deserves: stay on " +
|
||||||
|
"one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward. " +
|
||||||
|
"For off-map checks use ask_user_question.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The explain authoring contract: the commitment must exist. Empty
|
||||||
|
* result = an explain the dock can arm and the model can grade against. */
|
||||||
|
export function explainInputProblems(input: { correctAnswer: string }): string[] {
|
||||||
|
if (input.correctAnswer.trim().length === 0) {
|
||||||
|
return ["correctAnswer is required — commit to the full correct answer BEFORE posing the question."];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool names that carry the quizzing mechanic — the teach seam
|
||||||
|
* (phases), the plan-position fold, and the pending gate all recognize
|
||||||
|
* whichever is armed. */
|
||||||
|
export const QUIZ_TOOL_NAMES = ["quiz", "explain"] as const;
|
||||||
|
|
||||||
/** The TEACHER's quiz declaration (base + the position field). */
|
/** The TEACHER's quiz declaration (base + the position field). */
|
||||||
export const quizInputSchema = quizInputObject;
|
export const quizInputSchema = quizInputObject;
|
||||||
|
|
||||||
@@ -249,6 +311,8 @@ export const askInputSchema = Type.Object({
|
|||||||
|
|
||||||
export type QuizToolInput = Static<typeof quizInputObject>;
|
export type QuizToolInput = Static<typeof quizInputObject>;
|
||||||
export type AskToolInput = Static<typeof askInputSchema>;
|
export type AskToolInput = Static<typeof askInputSchema>;
|
||||||
|
export type ExplainToolInput = Static<typeof explainInputObject>;
|
||||||
|
export type ProbeExplainToolInput = Static<typeof explainInputBase>;
|
||||||
|
|
||||||
|
|
||||||
/** A real selected answer, in the order the options were displayed. */
|
/** A real selected answer, in the order the options were displayed. */
|
||||||
@@ -258,7 +322,7 @@ export interface OptionAnswer {
|
|||||||
index: number; // 1-based display index
|
index: number; // 1-based display index
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionKind = "quiz" | "ask";
|
export type QuestionKind = "quiz" | "ask" | "explain";
|
||||||
export type SelectMode = "single-select" | "multi-select";
|
export type SelectMode = "single-select" | "multi-select";
|
||||||
export type QuestionMode = SelectMode | "text";
|
export type QuestionMode = SelectMode | "text";
|
||||||
|
|
||||||
@@ -272,6 +336,8 @@ export interface QuestionData {
|
|||||||
options: QuizOption[]; // display order (post-shuffle)
|
options: QuizOption[]; // display order (post-shuffle)
|
||||||
// quiz only. NEVER send to the client before answering:
|
// quiz only. NEVER send to the client before answering:
|
||||||
correctIndices?: number[];
|
correctIndices?: number[];
|
||||||
|
// explain only. NEVER send to the client before answering:
|
||||||
|
committedAnswer?: string;
|
||||||
explanation?: string;
|
explanation?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -285,11 +351,12 @@ export interface QuestionAnswerData {
|
|||||||
dontKnow: boolean;
|
dontKnow: boolean;
|
||||||
answers: OptionAnswer[];
|
answers: OptionAnswer[];
|
||||||
other?: string; // ask: "Other" free text
|
other?: string; // ask: "Other" free text
|
||||||
text?: string; // ask: text-mode answer
|
text?: string; // ask/explain: text-mode answer
|
||||||
note?: string;
|
note?: string;
|
||||||
correct?: boolean; // quiz only
|
correct?: boolean; // quiz only
|
||||||
correctIndices?: number[]; // quiz only (rendering)
|
correctIndices?: number[]; // quiz only (rendering)
|
||||||
explanation?: string; // quiz only (rendering)
|
committed?: string; // explain only (rendering): the model's committed answer
|
||||||
|
explanation?: string; // quiz/explain only (rendering)
|
||||||
dismissed?: boolean;
|
dismissed?: boolean;
|
||||||
answeredAt: string;
|
answeredAt: string;
|
||||||
}
|
}
|
||||||
@@ -526,12 +593,18 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe
|
|||||||
base.explanation = qd.explanation;
|
base.explanation = qd.explanation;
|
||||||
base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []);
|
base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []);
|
||||||
}
|
}
|
||||||
|
if (qd.kind === "explain") {
|
||||||
|
// No client grading — the model grades in its reply, against the
|
||||||
|
// commitment. Snapshots ride along for the reveal + rendering.
|
||||||
|
base.committed = qd.committedAnswer;
|
||||||
|
base.explanation = qd.explanation;
|
||||||
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The user message that carries the answer into the agent's context. */
|
/** The user message that carries the answer into the agent's context. */
|
||||||
export function formatAnswerMessage(ad: QuestionAnswerData): string {
|
export function formatAnswerMessage(ad: QuestionAnswerData): string {
|
||||||
const label = ad.kind === "quiz" ? "Quiz answer" : "Answer to your question";
|
const label = ad.kind === "quiz" ? "Quiz answer" : ad.kind === "explain" ? "Explain answer" : "Answer to your question";
|
||||||
const head = `[${label}]`;
|
const head = `[${label}]`;
|
||||||
const lines: string[] = [head];
|
const lines: string[] = [head];
|
||||||
|
|
||||||
@@ -553,6 +626,18 @@ export function formatAnswerMessage(ad: QuestionAnswerData): string {
|
|||||||
lines.push(`Result: ${verdict}`);
|
lines.push(`Result: ${verdict}`);
|
||||||
}
|
}
|
||||||
lines.push(`Correct: ${correctStr}`);
|
lines.push(`Correct: ${correctStr}`);
|
||||||
|
} else if (ad.kind === "explain") {
|
||||||
|
if (ad.dontKnow) {
|
||||||
|
lines.push(
|
||||||
|
`The learner selected "I don't know" — they did NOT attempt an answer (a genuine knowledge gap, not a wrong guess).`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(`The learner answered: ${ad.text ?? "(no answer)"}`);
|
||||||
|
}
|
||||||
|
if (ad.committed) {
|
||||||
|
lines.push(`Your committed correct answer (immutable — grade against it, never revise it): ${ad.committed}`);
|
||||||
|
}
|
||||||
|
lines.push("Grade his answer strictly against your committed answer in your reply: name what he got and what he missed.");
|
||||||
} else {
|
} else {
|
||||||
if (ad.text) {
|
if (ad.text) {
|
||||||
lines.push(`The learner answered: ${ad.text}`);
|
lines.push(`The learner answered: ${ad.text}`);
|
||||||
|
|||||||
+12
-1
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
import { Value } from "typebox/value";
|
import { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, type AskToolInput, type QuizToolInput } from "./question-core.ts";
|
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, explainInputBase, explainInputProblems, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||||
import { restoreMathEscapes } from "./math-delims.ts";
|
import { restoreMathEscapes } from "./math-delims.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
@@ -103,6 +103,17 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Explain parse for DISPLAY: the shared authoring base (the teacher's
|
||||||
|
* nodeId is an unknown key Value.Clean strips, same as the quiz seam) —
|
||||||
|
* both explain variants share the same fields, and the commitment
|
||||||
|
* refinement (explainInputProblems) mirrors the boundary chain. */
|
||||||
|
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
|
||||||
|
const cleaned = boundaryParse<ExplainToolInput>(explainInputBase, restoreMathEscapesDeep(input));
|
||||||
|
if (!cleaned) return undefined;
|
||||||
|
if (explainInputProblems(cleaned).length > 0) return undefined;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
/** probe_done parse — the core-owned schema the tool itself declares. */
|
/** probe_done parse — the core-owned schema the tool itself declares. */
|
||||||
export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined {
|
export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined {
|
||||||
return boundaryParse<ProbeDoneInput>(probeDoneInputSchema, input);
|
return boundaryParse<ProbeDoneInput>(probeDoneInputSchema, input);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
RoleModelOverride,
|
RoleModelOverride,
|
||||||
} from "../core/settings.ts";
|
} from "../core/settings.ts";
|
||||||
import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
||||||
import { TOGGLEABLE_TOOLS } from "../core/pending.ts";
|
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
||||||
|
|
||||||
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
||||||
export type SettingsBodyResult<T> =
|
export type SettingsBodyResult<T> =
|
||||||
@@ -157,10 +157,11 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult<AgentS
|
|||||||
if (tools.map) {
|
if (tools.map) {
|
||||||
// Unknown tool keys are rejected at WRITE time (user decision
|
// Unknown tool keys are rejected at WRITE time (user decision
|
||||||
// 2026-09-19): the delta is inert while unread, but once the bridge
|
// 2026-09-19): the delta is inert while unread, but once the bridge
|
||||||
// honors it a persisted `quiz: false` would genuinely drop the quiz —
|
// honors it a persisted bogus key would drift. The writable set is
|
||||||
// and the definitive list is the adjudicated job-tools-only set.
|
// the job tools + the quizzing-mechanics switch (explain, owner
|
||||||
// The read-side sanitizer stays lenient for forward-compat.
|
// 2026-09-27). The read-side sanitizer stays lenient for
|
||||||
const supported = TOGGLEABLE_TOOLS as readonly string[];
|
// forward-compat.
|
||||||
|
const supported = WRITABLE_TOOL_KEYS as readonly string[];
|
||||||
for (const key of Object.keys(tools.map)) {
|
for (const key of Object.keys(tools.map)) {
|
||||||
if (!supported.includes(key)) {
|
if (!supported.includes(key)) {
|
||||||
return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` };
|
return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type FauxResponseStep,
|
type FauxResponseStep,
|
||||||
} from "@earendil-works/pi-ai";
|
} from "@earendil-works/pi-ai";
|
||||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
|
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||||
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
import { effectiveSettings } from "../src/server/stores.ts";
|
import { effectiveSettings } from "../src/server/stores.ts";
|
||||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||||
@@ -1050,3 +1051,84 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the explain mechanic end-to-end (free-recall, owner 2026-09-27) ───────
|
||||||
|
|
||||||
|
it("explain tool call persists as a pending part; the free-text answer reveals the commitment and reaches the model", async () => {
|
||||||
|
const EXPLAIN_INPUT = {
|
||||||
|
question: "Explain why Mercury is hard to observe.",
|
||||||
|
explanation: "It never strays far from the Sun in our sky.",
|
||||||
|
correctAnswer: "Its small angular separation from the Sun.",
|
||||||
|
nodeId: "planet-order",
|
||||||
|
};
|
||||||
|
const { engine, calls } = scriptedTeacher((cs) => [
|
||||||
|
(context) => {
|
||||||
|
cs.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
|
||||||
|
return fauxAssistantMessage(
|
||||||
|
[fauxThinking("Free recall this time."), fauxToolCall("explain", EXPLAIN_INPUT, { id: "call-1" })],
|
||||||
|
{ stopReason: "toolUse" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(context) => {
|
||||||
|
cs.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
|
||||||
|
return fauxAssistantMessage([fauxText("Right — it hugs the Sun.")]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const chat = createConfiguredChat("Test: explain mechanic");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
// Free-recall armed: the mechanic swap is the ONLY change to the registry.
|
||||||
|
const tools = createTeacherTools(
|
||||||
|
{ chatId: chat.id, chatStore, jobStore, spawn: () => {} },
|
||||||
|
{ explain: true },
|
||||||
|
);
|
||||||
|
assert.ok(tools.explain && !tools.quiz, "explain armed, quiz dropped — one quizzing tool");
|
||||||
|
|
||||||
|
const factory: Parameters<typeof handleChatRequest>[1] = (opts) =>
|
||||||
|
createTeacherAgent(undefined, { engine, messages: opts.messages, tools });
|
||||||
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), factory));
|
||||||
|
|
||||||
|
const saved1 = chatStore.getChat(chat.id)!;
|
||||||
|
const savedContent1 = (saved1.messages[1] as { content: unknown }).content as Array<{ type: string; name?: string }>;
|
||||||
|
const call = savedContent1.find((b) => b.type === "toolCall" && b.name === "explain");
|
||||||
|
assert.ok(call, "explain tool call persisted");
|
||||||
|
|
||||||
|
const pending = danglingQuestionOf(saved1.messages);
|
||||||
|
assert.equal(pending?.tool, "explain");
|
||||||
|
assert.deepEqual(pending?.input, EXPLAIN_INPUT);
|
||||||
|
|
||||||
|
const view1 = projectView(saved1.messages);
|
||||||
|
assert.ok(view1[1]!.parts.some((p) => p.type === "tool-explain"), "tool-explain part projected — the dock arms");
|
||||||
|
|
||||||
|
// The client answers in his own words (the dock's buildAnswer seam):
|
||||||
|
const qd: QuestionData = {
|
||||||
|
id: "call-1",
|
||||||
|
kind: "explain",
|
||||||
|
question: EXPLAIN_INPUT.question,
|
||||||
|
mode: "text",
|
||||||
|
options: [],
|
||||||
|
committedAnswer: EXPLAIN_INPUT.correctAnswer,
|
||||||
|
explanation: EXPLAIN_INPUT.explanation,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const answer = buildAnswer(qd, { text: "It stays near the Sun." });
|
||||||
|
assert.equal(answer.correct, undefined, "no client grading — the model grades");
|
||||||
|
|
||||||
|
await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer }), factory));
|
||||||
|
|
||||||
|
const saved2 = chatStore.getChat(chat.id)!;
|
||||||
|
const view2 = projectView(saved2.messages);
|
||||||
|
const part = view2[1]!.parts.find((p) => p.type === "tool-explain");
|
||||||
|
assert.equal((part as { state?: string }).state, "output-available");
|
||||||
|
const out = (part as { output?: { committed?: string; explanation?: string } }).output!;
|
||||||
|
assert.equal(out.committed, EXPLAIN_INPUT.correctAnswer, "the commitment snapshots into the answer record");
|
||||||
|
assert.equal(out.explanation, EXPLAIN_INPUT.explanation);
|
||||||
|
|
||||||
|
// The model's continuation saw the answer JSON (fork 1: the answer IS
|
||||||
|
// the call's tool result) — his text AND the commitment snapshot ride it.
|
||||||
|
const seen = calls[1]!.join("\n");
|
||||||
|
assert.match(seen, /It stays near the Sun\./);
|
||||||
|
assert.match(seen, /committed/);
|
||||||
|
assert.match(seen, /small angular separation/);
|
||||||
|
assert.equal(danglingQuestionOf(saved2.messages), null, "no pending question remains");
|
||||||
|
});
|
||||||
|
|||||||
@@ -181,3 +181,32 @@ test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; pla
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the explain mechanic at the seam (owner 2026-09-27) ───────────────────
|
||||||
|
// Both quizzing tools (quiz OR explain, whichever the settings armed) open
|
||||||
|
// teaching — the seam is structural, not name-bound to the MC tool.
|
||||||
|
|
||||||
|
test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => {
|
||||||
|
const done = toolResult("probe_done");
|
||||||
|
const explainCall = (args: Record<string, unknown>): LhMessage =>
|
||||||
|
({
|
||||||
|
id: "a_explain",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call_explain", name: "explain", arguments: args }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}) as unknown as LhMessage;
|
||||||
|
|
||||||
|
const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" });
|
||||||
|
const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" });
|
||||||
|
|
||||||
|
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged explain never starts teaching");
|
||||||
|
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged explain CALL is the seam");
|
||||||
|
assert.equal(
|
||||||
|
phaseAt3Lh([done, tagged]),
|
||||||
|
phaseAt3([
|
||||||
|
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
|
||||||
|
{ parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] },
|
||||||
|
]),
|
||||||
|
"the two folds agree on the explain mechanic",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -405,3 +405,47 @@ describe("derivePlanStatuses (the map tracks position, not completion)", () => {
|
|||||||
assert.equal(derivedNodeStatus(later, "c"), "upcoming");
|
assert.equal(derivedNodeStatus(later, "c"), "upcoming");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the explain mechanic derives position identically (owner 2026-09-27) ──
|
||||||
|
|
||||||
|
describe("derivePlanStatuses: tagged explain calls move the map like quiz calls", () => {
|
||||||
|
const tree: PlanTree = {
|
||||||
|
nodes: [
|
||||||
|
{ id: "a", title: "A" },
|
||||||
|
{ id: "b", title: "B" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "a", to: "b" }],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
const explainPart = (nodeId: string | undefined) => ({
|
||||||
|
type: "tool-explain",
|
||||||
|
state: "output-available",
|
||||||
|
input: nodeId === undefined ? {} : { nodeId },
|
||||||
|
});
|
||||||
|
type Part = { type: string; state: string; input: unknown; output?: unknown };
|
||||||
|
const msg = (...parts: Part[]) => ({ parts });
|
||||||
|
|
||||||
|
it("a tagged explain call is the current position; earlier ones are visited", () => {
|
||||||
|
const st = derivePlanStatuses(tree, [
|
||||||
|
msg(explainPart("a")),
|
||||||
|
msg(explainPart("b")),
|
||||||
|
]);
|
||||||
|
assert.equal(st.current, "b");
|
||||||
|
assert.deepEqual([...st.visited], ["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("untagged explain calls move nothing", () => {
|
||||||
|
const st = derivePlanStatuses(tree, [msg(explainPart(undefined))]);
|
||||||
|
assert.equal(st.current, null);
|
||||||
|
assert.equal(st.visited.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed mechanics (an old quiz record, then an explain) still derive one run", () => {
|
||||||
|
const st = derivePlanStatuses(tree, [
|
||||||
|
msg({ type: "tool-quiz", state: "output-available", input: { nodeId: "a" } }),
|
||||||
|
msg(explainPart("b")),
|
||||||
|
]);
|
||||||
|
assert.equal(st.current, "b");
|
||||||
|
assert.deepEqual([...st.visited], ["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
QUESTION_ANSWERED_ENTRY,
|
QUESTION_ANSWERED_ENTRY,
|
||||||
QUESTION_ENTRY,
|
QUESTION_ENTRY,
|
||||||
quizInputProblems,
|
quizInputProblems,
|
||||||
|
explainInputBase,
|
||||||
|
explainInputObject,
|
||||||
|
explainInputProblems,
|
||||||
quizInputSchema,
|
quizInputSchema,
|
||||||
probeQuizInputSchema,
|
probeQuizInputSchema,
|
||||||
resolveCorrect,
|
resolveCorrect,
|
||||||
@@ -543,3 +546,71 @@ describe("dock selection (toggleOption / toggleOther)", () => {
|
|||||||
assert.deepEqual([...sel.selected], [1]);
|
assert.deepEqual([...sel.selected], [1]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the explain tool: free-recall quizzing (owner 2026-09-27) ─────────────
|
||||||
|
|
||||||
|
describe("explainInputBase / explainInputProblems (the commitment contract)", () => {
|
||||||
|
it("commits the argument order: explanation (reasoning) BEFORE correctAnswer (the pick)", () => {
|
||||||
|
// Same ruling as the quiz (2026-09-25): models emit tool args in
|
||||||
|
// schema order — reasoning first, then the answer it supports.
|
||||||
|
const keys = Object.keys(explainInputBase.properties);
|
||||||
|
assert.ok(keys.indexOf("explanation") < keys.indexOf("correctAnswer"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the teach object adds the required nodeId AFTER the base fields", () => {
|
||||||
|
const keys = Object.keys(explainInputObject.properties);
|
||||||
|
assert.deepEqual(keys, [...Object.keys(explainInputBase.properties), "nodeId"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty commitment is the one authoring problem", () => {
|
||||||
|
assert.deepEqual(explainInputProblems({ correctAnswer: "Full speed is c." }), []);
|
||||||
|
assert.deepEqual(explainInputProblems({ correctAnswer: " " }), [
|
||||||
|
"correctAnswer is required — commit to the full correct answer BEFORE posing the question.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAnswer / formatAnswerMessage: explain", () => {
|
||||||
|
const explainQD = (overrides?: Partial<QuestionData>): QuestionData => ({
|
||||||
|
id: "e1",
|
||||||
|
kind: "explain",
|
||||||
|
question: "Why does the Michelson-Morley null result matter?",
|
||||||
|
mode: "text",
|
||||||
|
options: [],
|
||||||
|
committedAnswer: "It ruled out the luminiferous aether and set up special relativity.",
|
||||||
|
explanation: "A stationary aether would produce a fringe shift; none was observed.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots the commitment + explanation; the client NEVER grades free text", () => {
|
||||||
|
const ad = buildAnswer(explainQD(), { text: "Something about light speed being constant?" });
|
||||||
|
assert.equal(ad.correct, undefined, "no client verdict — the model grades in its reply");
|
||||||
|
assert.equal(ad.correctIndices, undefined);
|
||||||
|
assert.equal(ad.committed, "It ruled out the luminiferous aether and set up special relativity.");
|
||||||
|
assert.equal(ad.text, "Something about light speed being constant?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dontKnow records the gap with no attempted text", () => {
|
||||||
|
// The DOCK never sends text alongside dontKnow (its submit gates it);
|
||||||
|
// buildAnswer stays a pure mapping of the request it receives.
|
||||||
|
const ad = buildAnswer(explainQD(), { dontKnow: true });
|
||||||
|
assert.equal(ad.dontKnow, true);
|
||||||
|
assert.equal(ad.text, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats the model's view: his answer, the immutable commitment, and the grading instruction", () => {
|
||||||
|
const text = formatAnswerMessage(buildAnswer(explainQD(), { text: "No idea about aether." }));
|
||||||
|
assert.ok(text.startsWith("[Explain answer]"));
|
||||||
|
assert.match(text, /The learner answered: No idea about aether\./);
|
||||||
|
assert.match(text, /Your committed correct answer \(immutable/);
|
||||||
|
assert.match(text, /luminiferous aether/);
|
||||||
|
assert.match(text, /Grade his answer strictly/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats dontKnow as a gap, not a guess", () => {
|
||||||
|
const text = formatAnswerMessage(buildAnswer(explainQD(), { dontKnow: true }));
|
||||||
|
assert.match(text, /did NOT attempt/);
|
||||||
|
assert.ok(!text.includes("The learner answered:"), "no phantom answer beside the gap");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
createProberTools,
|
createProberTools,
|
||||||
createTeacherTools,
|
createTeacherTools,
|
||||||
probeQuizTool,
|
probeQuizTool,
|
||||||
|
probeExplainTool,
|
||||||
|
teachExplainToolFor,
|
||||||
teachQuizToolFor,
|
teachQuizToolFor,
|
||||||
type TeacherToolContext,
|
type TeacherToolContext,
|
||||||
} from "../src/agent/tools.ts";
|
} from "../src/agent/tools.ts";
|
||||||
@@ -228,10 +230,16 @@ describe("tool toggles (core/pending.ts + factories)", () => {
|
|||||||
const noAsk = createProberTools(ctx, { ask_user_question: false });
|
const noAsk = createProberTools(ctx, { ask_user_question: false });
|
||||||
assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]);
|
assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]);
|
||||||
|
|
||||||
// quiz off is honored by the generic machinery too (future toggleable),
|
// Quizzing is mechanics-managed now (owner 2026-09-27): quiz/explain
|
||||||
// but probe_done is never droppable — it is how the phase ends.
|
// NEVER pass through the generic filter — hand-written quiz:false is a
|
||||||
|
// no-op (the wedge danger stands: a quiz-less prober collapses its
|
||||||
|
// assessment flow). probe_done is never droppable — it ends the phase.
|
||||||
const noQuiz = createProberTools(ctx, { quiz: false });
|
const noQuiz = createProberTools(ctx, { quiz: false });
|
||||||
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "read", "research"]);
|
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]);
|
||||||
|
// The mechanic switch (owner 2026-09-27): tools.explain === true arms
|
||||||
|
// free-recall — explain in, quiz out, exactly one quizzing tool.
|
||||||
|
const free = createProberTools(ctx, { explain: true });
|
||||||
|
assert.deepEqual(Object.keys(free).sort(), ["ask_user_question", "explain", "find", "grep", "ls", "pdf", "probe_done", "read", "research"]);
|
||||||
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
|
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,6 +263,16 @@ describe("tool toggles (core/pending.ts + factories)", () => {
|
|||||||
full,
|
full,
|
||||||
"absent keys mean enabled — old records behave exactly as today",
|
"absent keys mean enabled — old records behave exactly as today",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The mechanic switch (owner 2026-09-27): free-recall swaps the teach
|
||||||
|
// quiz for the teach explain — one quizzing tool, whichever is armed.
|
||||||
|
const freeFull = ["ask_user_question", "demo", "explain", "find", "grep", "ls", "pdf", "plan_init", "read", "research", "visual"];
|
||||||
|
assert.deepEqual(Object.keys(createTeacherTools(ctx, { explain: true })).sort(), freeFull);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(createTeacherTools(ctx, { explain: true, research: false })).sort(),
|
||||||
|
freeFull.filter((t) => t !== "research"),
|
||||||
|
"mechanics and plain toggles compose",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,3 +311,78 @@ describe("prepareQuestionArguments (the string-encoded options wobble)", () => {
|
|||||||
assert.equal(prepare(unparseable), unparseable, "unparseable strings pass through — validation reports honestly");
|
assert.equal(prepare(unparseable), unparseable, "unparseable strings pass through — validation reports honestly");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the explain tool boundary (free-recall, owner 2026-09-27) ─────────────
|
||||||
|
|
||||||
|
describe("teachExplainToolFor: the explain variant validates nodeId like the quiz", () => {
|
||||||
|
const validExplain = {
|
||||||
|
question: "Explain why the null result matters.",
|
||||||
|
explanation: "A stationary aether predicts a fringe shift.",
|
||||||
|
correctAnswer: "It ruled out the aether.",
|
||||||
|
nodeId: "arith",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts a nodeId matching a tree node", async () => {
|
||||||
|
const chatId = newChat("Explain nodeId: valid");
|
||||||
|
chatStore.saveTree(chatId, {
|
||||||
|
nodes: [
|
||||||
|
{ id: "arith", title: "Arithmetic" },
|
||||||
|
{ id: "expressions", title: "Expressions" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "arith", to: "expressions" }],
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), {
|
||||||
|
...validExplain,
|
||||||
|
nodeId: "expressions",
|
||||||
|
});
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an off-map nodeId with the slugs listed", async () => {
|
||||||
|
const chatId = newChat("Explain nodeId: typo");
|
||||||
|
chatStore.saveTree(chatId, { nodes: [{ id: "arith", title: "A" }], edges: [], version: 1 });
|
||||||
|
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), {
|
||||||
|
...validExplain,
|
||||||
|
nodeId: "arithmetic",
|
||||||
|
});
|
||||||
|
assert.equal(res.success, false);
|
||||||
|
assert.match(problemLines(res.error).join("\n"), /matches no node/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no tree yet: rejected — checks wait for plan_init", async () => {
|
||||||
|
const chatId = newChat("Explain nodeId: no tree");
|
||||||
|
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), validExplain);
|
||||||
|
assert.equal(res.success, false);
|
||||||
|
assert.match(problemLines(res.error).at(-1)!, /No plan map exists yet — call plan_init/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty commitment is rejected at the boundary", async () => {
|
||||||
|
const chatId = newChat("Explain: empty commitment");
|
||||||
|
chatStore.saveTree(chatId, { nodes: [{ id: "arith", title: "A" }], edges: [], version: 1 });
|
||||||
|
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), {
|
||||||
|
...validExplain,
|
||||||
|
correctAnswer: " ",
|
||||||
|
});
|
||||||
|
assert.equal(res.success, false);
|
||||||
|
assert.match(problemLines(res.error).join("\n"), /correctAnswer is required/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the prober's explain: no nodeId field at all", () => {
|
||||||
|
it("the probe schema carries no nodeId; the commitment contract still binds", async () => {
|
||||||
|
assert.ok(!("nodeId" in probeExplainTool.parameters.properties));
|
||||||
|
const res = await boundaryParse(probeExplainTool, {
|
||||||
|
question: "Explain what a derivative measures.",
|
||||||
|
explanation: "It is instantaneous rate of change.",
|
||||||
|
correctAnswer: "The instantaneous rate of change.",
|
||||||
|
});
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
const empty = await boundaryParse(probeExplainTool, {
|
||||||
|
question: "q",
|
||||||
|
explanation: "e",
|
||||||
|
correctAnswer: " ",
|
||||||
|
});
|
||||||
|
assert.equal(empty.success, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -457,3 +457,39 @@ describe("probe-done card", () => {
|
|||||||
assert.match(html, /Writing the assessment/);
|
assert.match(html, /Writing the assessment/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the explain reveal (free-recall, owner 2026-09-27) ────────────────────
|
||||||
|
|
||||||
|
describe("AnsweredCard: the explain variant", () => {
|
||||||
|
const explainQD = (): QuestionData => ({
|
||||||
|
id: "e1",
|
||||||
|
kind: "explain",
|
||||||
|
question: "Explain why the null result matters.",
|
||||||
|
mode: "text",
|
||||||
|
options: [],
|
||||||
|
committedAnswer: "It ruled out the luminiferous aether.",
|
||||||
|
explanation: "A stationary aether predicts a fringe shift; none was seen.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the committed answer + explanation with NO verdict chips", () => {
|
||||||
|
const html = renderToStaticMarkup(<AnsweredCard answer={buildAnswer(explainQD(), { text: "Something about relativity?" })} />);
|
||||||
|
assert.match(html, /Explain/, "labeled as the explain kind");
|
||||||
|
assert.match(html, /The answer:/);
|
||||||
|
assert.match(html, /luminiferous aether/);
|
||||||
|
assert.match(html, /Why:/);
|
||||||
|
assert.ok(!html.includes("incorrect") && !html.includes("correct</") , "no client grading");
|
||||||
|
assert.ok(!html.includes('data-mark="x"') && !html.includes('data-mark="check"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders his free text as the answer", () => {
|
||||||
|
const html = renderToStaticMarkup(<AnsweredCard answer={buildAnswer(explainQD(), { text: "Something about relativity?" })} />);
|
||||||
|
assert.match(html, /Something about relativity\?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dontKnow renders as the gap, not a wrong answer", () => {
|
||||||
|
const html = renderToStaticMarkup(<AnsweredCard answer={buildAnswer(explainQD(), { dontKnow: true })} />);
|
||||||
|
assert.match(html, /I don't know/);
|
||||||
|
assert.ok(!html.includes("The learner answered:"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
composeSearchEngine,
|
composeSearchEngine,
|
||||||
composeSlotModel,
|
composeSlotModel,
|
||||||
composeTool,
|
composeTool,
|
||||||
|
composeQuizMechanics,
|
||||||
researchToolUnavailable,
|
researchToolUnavailable,
|
||||||
sectionedSegments,
|
sectionedSegments,
|
||||||
} from "../components/settings/agent-section.tsx";
|
} from "../components/settings/agent-section.tsx";
|
||||||
@@ -388,3 +389,19 @@ describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
|
|||||||
assert.equal(PROMPT_IN_USE_HINT, "Your version is in use");
|
assert.equal(PROMPT_IN_USE_HINT, "Your version is in use");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── the quizzing-mechanics segmented control (owner 2026-09-27) ───────────
|
||||||
|
|
||||||
|
describe("composeQuizMechanics (the segmented control's write)", () => {
|
||||||
|
it("free recall writes explain:true; multiple choice writes explain:false — nothing else moves", () => {
|
||||||
|
const before: AgentSettings = { tools: { research: false }, prompts: { teacher: true } } as AgentSettings;
|
||||||
|
const free = composeQuizMechanics(before, "free");
|
||||||
|
assert.equal(free.tools?.explain, true);
|
||||||
|
const choice = composeQuizMechanics(before, "choice");
|
||||||
|
assert.equal(choice.tools?.explain, false);
|
||||||
|
for (const after of [free, choice]) {
|
||||||
|
assert.equal(after.tools?.research, false, "the research toggle carries verbatim");
|
||||||
|
assert.deepEqual(after.prompts, before.prompts, "the prompts map carries verbatim");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
parseAskInput,
|
parseAskInput,
|
||||||
parseProbeDoneInput,
|
parseProbeDoneInput,
|
||||||
parseQuizInput,
|
parseQuizInput,
|
||||||
|
parseExplainInput,
|
||||||
} from "../src/core/tool-parts.ts";
|
} from "../src/core/tool-parts.ts";
|
||||||
import { quizInputSchema, probeQuizInputSchema } from "../src/core/question-core.ts";
|
import { quizInputSchema, probeQuizInputSchema } from "../src/core/question-core.ts";
|
||||||
import { probeDoneInputSchema } from "../src/core/contracts/probe.ts";
|
import { probeDoneInputSchema } from "../src/core/contracts/probe.ts";
|
||||||
@@ -266,3 +267,42 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
|
|||||||
assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?");
|
assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── parseExplainInput (free-recall display parse, owner 2026-09-27) ───────
|
||||||
|
|
||||||
|
describe("parseExplainInput: the dock's display seam for explain calls", () => {
|
||||||
|
const good = {
|
||||||
|
question: "Explain why Mercury is hard to observe.",
|
||||||
|
explanation: "It never strays far from the Sun in our sky.",
|
||||||
|
correctAnswer: "Its small angular separation from the Sun.",
|
||||||
|
nodeId: "planet-order", // the teacher's field — STRIPPED by Clean, like quiz
|
||||||
|
details: "Think about where it appears at dusk.",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("parses a valid call and strips the teacher-only nodeId", () => {
|
||||||
|
const parsed = parseExplainInput(good)!;
|
||||||
|
assert.equal(parsed.question, "Explain why Mercury is hard to observe.");
|
||||||
|
assert.equal(parsed.correctAnswer, "Its small angular separation from the Sun.");
|
||||||
|
assert.equal(parsed.details, "Think about where it appears at dusk.");
|
||||||
|
assert.ok(!("nodeId" in parsed), "the teacher-only key is cleaned away");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty commitment never arms the dock", () => {
|
||||||
|
assert.equal(parseExplainInput({ ...good, correctAnswer: " " }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores LaTeX escape debris inside math spans (the pi-ai streaming wobble)", () => {
|
||||||
|
// \f of \forall was eaten by the lenient args parse: U+000C + "orall".
|
||||||
|
const dirty = "Because \(orall x\) holds.";
|
||||||
|
const parsed = parseExplainInput({ ...good, correctAnswer: dirty })!;
|
||||||
|
assert.equal(parsed.correctAnswer, "Because \(\forall x\) holds.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wrong-shape input never arms the dock (numeric strings DO coerce — the seam's leniency)", () => {
|
||||||
|
assert.equal(parseExplainInput("just a string"), undefined);
|
||||||
|
assert.equal(parseExplainInput({ explanation: "e", correctAnswer: "a" }), undefined, "missing question");
|
||||||
|
assert.equal(parseExplainInput({ question: "q", explanation: "e", correctAnswer: { nested: true } }), undefined);
|
||||||
|
// 42 → "42": the same numeric coercion the quiz seam pins.
|
||||||
|
assert.equal(parseExplainInput({ question: "q", explanation: "e", correctAnswer: 42 })?.correctAnswer, "42");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -81,10 +81,16 @@ describe("parseAgentSettingsBody: tool keys validate at write time", () => {
|
|||||||
assert.deepEqual(r.ok && r.settings.tools, { research: false, pdf: false });
|
assert.deepEqual(r.ok && r.settings.tools, { research: false, pdf: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the quizzing-mechanics key (explain) is writable (owner 2026-09-27)", () => {
|
||||||
|
const r = parse({ explain: true });
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.deepEqual(r.ok && r.settings.tools, { explain: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("unknown keys are rejected with the supported list (the quiz wedge never persists)", () => {
|
it("unknown keys are rejected with the supported list (the quiz wedge never persists)", () => {
|
||||||
const r = parse({ quiz: false });
|
const r = parse({ quiz: false });
|
||||||
assert.equal(r.ok, false);
|
assert.equal(r.ok, false);
|
||||||
assert.match(!r.ok && r.error, /unknown tool "quiz" \(supported: research, demo, visual, pdf\)/);
|
assert.match(!r.ok && r.error, /unknown tool "quiz" \(supported: research, demo, visual, pdf, explain\)/);
|
||||||
const typo = parse({ reasearch: false });
|
const typo = parse({ reasearch: false });
|
||||||
assert.equal(typo.ok, false);
|
assert.equal(typo.ok, false);
|
||||||
assert.match(!typo.ok && typo.error, /unknown tool "reasearch"/);
|
assert.match(!typo.ok && typo.error, /unknown tool "reasearch"/);
|
||||||
|
|||||||
Reference in New Issue
Block a user