diff --git a/src/agent/prompts/prober/10-probe-doctrine.md b/src/agent/prompts/prober/10-probe-doctrine.md index c02da9f..fbede04 100644 --- a/src/agent/prompts/prober/10-probe-doctrine.md +++ b/src/agent/prompts/prober/10-probe-doctrine.md @@ -4,7 +4,7 @@ Probing is not a warm-up. It is the whole job of this phase, and it is the highe ## 1a. His current level — use `quiz`. This is a mapping job, not a spot-check. -Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, the teacher cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush. +Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, the teacher cannot teach into it. And measurement should be efficient: every question you ask should move an edge somewhere — bracket a strand, narrow a bracket, or characterize a miss. No filler questions, no re-asking what his last answer already showed you. When a strand is bracketed, it's done: move to the next. **The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing. diff --git a/src/agent/prompts/teacher/21-question-principles.md b/src/agent/prompts/teacher/21-question-principles.md new file mode 100644 index 0000000..686bed1 --- /dev/null +++ b/src/agent/prompts/teacher/21-question-principles.md @@ -0,0 +1,9 @@ +# Question principles — what a teach-phase check must be + +Every check exists to answer one question: did the idea actually **take** — not whether he can read it back. The principles: + +- **Answerable by reasoning from the session.** Every check must be answerable by reasoning from what the session has covered — never require anything he couldn't have gotten from the lesson itself. A check he has no route to is not a check, it's a coin flip. +- **Never just reciting your words back.** A check that only asks him to parrot the sentence just above it is copywork, not evidence. +- **Prefer problems that apply the knowledge.** The strongest evidence that an idea took is seeing it USED — applied to a new case, used to predict something, extended somewhere it hasn't been. Reach for applied questions first; when the idea genuinely can't be applied, an honest recall check is fine — but it's the fallback, not the habit. +- **Catch confusion early, and make it nameable.** Design the wrong options as real misconceptions — each the answer he'd give under a specific wrong model. Then a miss TELLS you which wrong model he holds; diagnose and repair before building anything on top of it. +- **His answer is signal, not verdict.** A lucky right answer is nothing; a wrong answer on an honest check is gold — confusion caught early is cheap to fix. \ No newline at end of file diff --git a/src/agent/tools.ts b/src/agent/tools.ts index ee90e06..a7822cf 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -5,6 +5,7 @@ import { quizInputObject, quizInputProblems, } from "../core/question-core.ts"; +import { filterEnabledTools, type EnabledTools } from "../core/pending.ts"; import type { JobRecord } from "../core/contracts.ts"; import type { JobStore } from "../core/ports.ts"; import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts"; @@ -62,12 +63,28 @@ export const probeQuizTool: LhTool = { schema: probeQuizInputSchema, }; -/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. */ -export function createProberTools(ctx: ProbeDoneContext): Record> { +/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. + * `enabled` is the resolved settings seam (core/pending.ts): any tool + * explicitly false is dropped from the registry. probe_done stays + * always-on regardless — it is how the phase ENDS; a prober that can + * never conclude is a lockout. */ +export function createProberTools( + ctx: ProbeDoneContext, + enabled?: EnabledTools, +): Record> { + // probe_done is re-added AFTER the filter, always-on by construction — it + // is how the phase ENDS; a prober that can never conclude is a lockout. + const done = probeDoneTool(ctx); return { - quiz: probeQuizTool, - ask_user_question: askUserQuestionTool, - ...probeDoneTool(ctx), + ...filterEnabledTools( + { + quiz: probeQuizTool, + ask_user_question: askUserQuestionTool, + ...done, + }, + enabled, + ), + ...done, }; } @@ -131,12 +148,25 @@ export interface TeacherToolContext extends PlanToolContext { /** * Per-request teacher tool registry: teach questions (validated nodeId) + * plan tools + job tools, all closing over the ctx. + * + * `enabled` is the resolved settings seam (core/pending.ts EnabledTools — + * Platform's chat-handler wiring passes it through from settings). Today + * only the sub-agent tools (research/demo/visual) are user-toggleable, but + * the filtering is GENERIC: any name explicitly false is dropped, so + * future toggleables (question-tool variants, …) need no change here. + * Absent/undefined = enabled — tests and old records see today's set. */ -export function createTeacherTools(ctx: TeacherToolContext): Record> { - return { - quiz: teachQuizToolFor(ctx), - ask_user_question: askUserQuestionTool, - ...planTools(ctx), - ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }), - }; +export function createTeacherTools( + ctx: TeacherToolContext, + enabled?: EnabledTools, +): Record> { + return filterEnabledTools( + { + quiz: teachQuizToolFor(ctx), + ask_user_question: askUserQuestionTool, + ...planTools(ctx), + ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }), + }, + enabled, + ); } diff --git a/src/core/pending.ts b/src/core/pending.ts index 078e320..038f804 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -30,6 +30,38 @@ export const QUESTION_TOOLS = ["quiz", "ask_user_question"] as const; /** Tools fulfilled by background sub-agent jobs (W2/W6). */ export const JOB_TOOLS = ["research", "demo", "visual"] as const; +// ─── Tool availability (2026-09 settings seam) ───────────────────────────── + +/** The tools a user may toggle off in settings (Wave 2 T2a, user-decided + * 2026-09-15): for now only the SUB-AGENT dispatch tools — quiz, + * ask_user_question (and the read tool, when it lands) are always on; + * the long-term vision is that everything becomes toggleable as new + * tool variants (e.g. alternative question tools) ship, so the factory + * seam below is GENERIC: it honors any tool name it receives. New + * toggleables join this list when settings exposes them. + * The names are the MODEL-FACING tool names (research, not researcher). */ +export const TOGGLEABLE_TOOLS = ["research", "demo", "visual"] as const; + +/** The resolved settings → factory seam shape (Platform wires chat-handler + * to pass this through from the settings record; storage and defaults are + * Platform's lane). JSON-native: a partial record keyed by tool name. + * Absent key / undefined = enabled — old records behave exactly as today. + * Any explicit `false` removes that tool from the registry, whatever its + * name — the machinery stays generic for future toggleables. */ +export type EnabledTools = Partial>; + +/** Drop every tool the resolved settings mark as disabled. The single + * filtering site every factory shares (prober/teacher). */ +export function filterEnabledTools(tools: Record, enabled?: EnabledTools): Record { + if (!enabled) return tools; + const out: Record = {}; + for (const [name, tool] of Object.entries(tools)) { + if (enabled[name] === false) continue; + out[name] = tool; + } + return out; +} + function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null { for (let m = messages.length - 1; m >= 0; m--) { const msg = messages[m]!; diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index 34e7c35..a87d14f 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -949,6 +949,29 @@ describe("prompt composition", () => { } }); + it("the question principles compose into the teacher prompt; the probe's quick-measurement revision landed", () => { + const teacher = composeInstructions("teacher"); + // the T2a doctrine fragment (user-locked wording 2026-09-15): verbatim presence + for (const pin of [ + "Answerable by reasoning from the session", + "Never just reciting your words back", + "Prefer problems that apply the knowledge", + "Catch confusion early, and make it nameable", + ]) { + assert.ok(teacher.includes(pin), `teacher prompt lost the principle: ${pin}`); + } + // it loads in sorted fragment order: right after 20-teaching, before 30-jobs + assert.ok( + teacher.indexOf("Question principles") > teacher.indexOf("# Teach — the loop"), + "principles sit after the teaching loop", + ); + // the prober's doctrine: the "no rush" framing is gone, quick measurement in + const prober = composeInstructions("prober"); + assert.ok(!prober.includes("There is no rush"), "the old no-rush framing is retired"); + assert.ok(prober.includes("measurement should be efficient"), "the quick-measurement directive is in"); + assert.ok(!prober.includes("Question principles"), "the teach-phase fragment never composes into the prober"); + }); + it("guards prompt size — no fragment can silently balloon (the README-leak class)", () => { // The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every // teacher turn until the numeric-prefix filter stopped it. This guard diff --git a/tests/question-tools.test.ts b/tests/question-tools.test.ts index eba1712..c572455 100644 --- a/tests/question-tools.test.ts +++ b/tests/question-tools.test.ts @@ -14,7 +14,9 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; import { after, describe, it } from "node:test"; -import { createProberTools, teachQuizToolFor } from "../src/agent/tools.ts"; +import { createProberTools, createTeacherTools, teachQuizToolFor, type TeacherToolContext } from "../src/agent/tools.ts"; +import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts"; +import { jobStore } from "../src/store/jobs.ts"; import { probeQuizInputSchema } from "../src/core/question-core.ts"; import { chatStore } from "../src/store/chats.ts"; import { PATHS } from "../config.ts"; @@ -148,4 +150,51 @@ describe("the prober's quiz: no nodeId field at all", () => { const schema = quizSchemaOf(tools); assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail"); }); -}); \ No newline at end of file +}); +// ─── tool availability (2026-09 settings seam) ─────────────────────────────── + +describe("tool toggles (core/pending.ts + factories)", () => { + it("TOGGLEABLE_TOOLS: today only the sub-agent dispatch tools; quiz/ask stay off the list", () => { + // The user-toggleable vocabulary Platform's settings validates against. + // quiz/ask_user_question are always-on FOR NOW (user-decided 2026-09-15); + // they join when settings exposes them — the factory seam is generic. + assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual"]); + }); + + it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => { + const chatId = newChat("Prober: toggles"); + const ctx = { chatId, chatStore, deliver: () => {} }; + assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "probe_done", "quiz"]); + + const noAsk = createProberTools(ctx, { ask_user_question: false }); + assert.deepEqual(Object.keys(noAsk).sort(), ["probe_done", "quiz"]); + + // quiz off is honored by the generic machinery too (future toggleable), + // but probe_done is never droppable — it is how the phase ends. + const noQuiz = createProberTools(ctx, { quiz: false }); + assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "probe_done"]); + assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on"); + }); + + it("createTeacherTools: {research: false} removes exactly research; unknown keys are no-ops", () => { + const chatId = newChat("Teacher: toggles"); + const ctx: TeacherToolContext = { + chatId, + chatStore, + jobStore, + spawn: () => {}, + }; + const full = Object.keys(createTeacherTools(ctx)).sort(); + assert.deepEqual(full, ["ask_user_question", "demo", "plan_init", "quiz", "research", "visual"]); + + const noResearch = Object.keys(createTeacherTools(ctx, { research: false })).sort(); + assert.deepEqual(noResearch, ["ask_user_question", "demo", "plan_init", "quiz", "visual"]); + + // absent = enabled: an empty record and undefined behave identically + assert.deepEqual( + Object.keys(createTeacherTools(ctx, {})).sort(), + full, + "absent keys mean enabled — old records behave exactly as today", + ); + }); +});