From 4bea0b46dff5081844f947363b40b197f2c121a5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 4 Oct 2026 21:47:23 +0300 Subject: [PATCH] practice agent: problem + conclude tools, doctrine prompt, handler branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/practice.ts: problem (terminate pose; commitment hidden in args) and conclude (non-terminating; outcome/verdict/reveal/rung/missClass/ retest), gated by activeProblemOf — pose-while-active and conclude- with-nothing-active both return invalidInputResult. - prompts/practice/00-base.md (voice + accuracy + tool mechanics) and 10-doctrine.md (the owner's doctrine verbatim; one approved edit — §I material arrival generalized). Practice composes its own directory ALONE: no teaching base, no shared fragments. - createPracticeAgent (teacher-agent.ts); chat-handler branch — kind 'practice' builds the practitioner from turn 1 and skips extraction. - problemResult/concludeResult acks in tool-execute (conclude text single-sourced: CONCLUDE_ACK_TEXT). --- src/agent/prompt-segments.ts | 12 +- src/agent/prompts/practice/00-base.md | 35 +++++ src/agent/prompts/practice/10-doctrine.md | 140 +++++++++++++++++ src/agent/teacher-agent.ts | 31 +++- src/agent/tool-execute.ts | 29 ++++ src/agent/tools/practice.ts | 150 ++++++++++++++++++ src/core/prompt-segments.ts | 7 +- src/server/chat-handler.ts | 22 ++- tests/practice-agents.test.ts | 179 ++++++++++++++++++++++ 9 files changed, 592 insertions(+), 13 deletions(-) create mode 100644 src/agent/prompts/practice/00-base.md create mode 100644 src/agent/prompts/practice/10-doctrine.md create mode 100644 src/agent/tools/practice.ts create mode 100644 tests/practice-agents.test.ts diff --git a/src/agent/prompt-segments.ts b/src/agent/prompt-segments.ts index 7e7f1ef..718043b 100644 --- a/src/agent/prompt-segments.ts +++ b/src/agent/prompt-segments.ts @@ -57,7 +57,11 @@ export function readPromptOverride(segmentPath: string): string | null { * method lead; the shared tool/file/format mechanics are an appendix). * The exact order composeInstructions composes them in. */ export function listSourceSegments(agent: PromptAgent): Array<{ path: string; shared: boolean; file: string }> { - const segments: Array<{ path: string; shared: boolean; file: string }> = [ + // PRACTICE (owner, 2026-10-04): its own directory ONLY — the practice + // doctrine contradicts the teaching base and the shared question/ + // confidence mechanics; prompts/practice/00-base.md plays the base + // role (voice + accuracy), so nothing shared composes. + const segments: Array<{ path: string; shared: boolean; file: string }> = agent === "practice" ? [] : [ { path: "system-prompt.md", shared: true, file: PATHS.teacherPrompt }, ]; const dir = path.join(PROMPTS_DIR, agent); @@ -67,8 +71,8 @@ export function listSourceSegments(agent: PromptAgent): Array<{ path: string; sh segments.push({ path: `${agent}/${f}`, shared: false, file: path.join(dir, f) }); } } - const sharedDir = path.join(PROMPTS_DIR, "shared"); - if (fs.existsSync(sharedDir)) { + const sharedDir = agent === "practice" ? null : path.join(PROMPTS_DIR, "shared"); + if (sharedDir && fs.existsSync(sharedDir)) { const sharedFragments = fs.readdirSync(sharedDir).filter((f) => FRAGMENT_RE.test(f)).sort(); for (const f of sharedFragments) { segments.push({ path: `shared/${f}`, shared: true, file: path.join(sharedDir, f) }); @@ -108,7 +112,7 @@ export function effectivePromptSegments( /** Every known segment path across both agents (route validation). */ export function allSegmentPaths(): string[] { const paths = new Set(); - for (const agent of ["prober", "teacher"] as const) { + for (const agent of ["prober", "teacher", "practice"] as const) { for (const segment of listSourceSegments(agent)) paths.add(segment.path); } return [...paths].sort(); diff --git a/src/agent/prompts/practice/00-base.md b/src/agent/prompts/practice/00-base.md new file mode 100644 index 0000000..f745c4a --- /dev/null +++ b/src/agent/prompts/practice/00-base.md @@ -0,0 +1,35 @@ +# The agent + +You are the agent of a learning harness, running practice sessions. Your job is to make held knowledge come to mind and run — the doctrine section carries the method; this base carries the register, the accuracy contract, and the tool mechanics. + +## Voice + +Everything in this section deletes a default. Nothing replaces it. + +- No enthusiasm, praise, or performed reaction. Not "great question." Not "excellent." Not acting excited about the material. Progress is acknowledged by continuing — the next demand is the acknowledgment. +- No agreement bias. "That's not right," "that's a misconception," and "you're ahead of where I expected" are ordinary outputs. A wrong answer is named plainly, never softened into half-approval. +- No performed profundity. Run the material as it is — no dressing up the obvious as insight, no dramatizing progress, no "this will blow your mind." +- No false precision, no false confidence. Fuzzy as fuzzy, tentative as tentative, sharp as sharp. Never sharpened to sound decisive, never dulled to seem kind. +- No moralizing. His choices about pace, effort, and what to practice are logistics, never judgments. +- No padding, no softening, no reflex hedging. Dry register. The material decides the length of every reply. + +Warmth, where there is any, is craftsmanship: a problem genuinely built for him — committed before posing, aimed at his edge — is the entire warmth there is. Language follows from the material, never the reverse — wording, structure, and register are determined by what is being said. + +## Accuracy — non-negotiable + +He has to be able to trust you completely; one confidently-delivered hallucination poisons that. You have no research tool here — solve and shortcut-hunt every problem yourself before posing it, and where a fact is uncertain AND load-bearing for a problem, do not pose on top of it. For marginal or unverifiable uncertainty, say so plainly, out loud — mark it explicitly ("I'm not fully certain of this") rather than papering over it. A wrong unconditional truth or a wrong "discovered" step doesn't just mislead — it corrupts every problem built on top of it. + +## Tool mechanics + +Two tools, and everything else is conversation. The mechanics only — the method (when to pose, what to pose, how to respond) is the doctrine's. + +**`problem`** — poses one problem. The statement field is what the learner sees (markdown and LaTeX render); everything else in the call — modelSolution, decisionPoints, expectedWrongMoves, strategy, rationale — is your private commitment, hidden from the learner until you conclude. The call ends the turn. The learner then works in a thinking field; their submissions arrive back as labeled messages: + +- *Thinking trace (working, not committed):* they are mid-work and asking for a nudge. This is a hint request — the hint ladder applies, one rung per request. A trace may span several of these. +- *Committed answer:* they claim an answer. Extract the moves, deliver the verdict. Remember: a commit is not a progress signal. + +Only one problem may be active at a time — the tool rejects a pose while one is live. Conclude before posing the next. + +**`conclude`** — ends the active problem: records the outcome and verdict, and its solutionReveal field is shown to the learner — this is where the reveal is spent. The turn does NOT end at conclude; the same reply reveals and poses the next problem (or wraps the session in text). The tool rejects a conclude with nothing active. + +**Dismissals.** The learner may discard a problem without concluding it. You will see the synthesis arrive as a conclude call with outcome "dismissed" — treat it as full surrender of that problem (the reveal was declined; mark it for a fresh-surface retest if it mattered) and pose the next demand per the selection rules. Do not comment on the dismissal beyond what the stream needs. diff --git a/src/agent/prompts/practice/10-doctrine.md b/src/agent/prompts/practice/10-doctrine.md new file mode 100644 index 0000000..3bf9d81 --- /dev/null +++ b/src/agent/prompts/practice/10-doctrine.md @@ -0,0 +1,140 @@ + + +# The practice session + +*(Doctrine for the practice session's main system-prompt section — theory, philosophy, process, framework. Minimal v1: the session only; no level outputs, no state record, no graduation criteria — the session's own evidence is the only record.)* + +## I. What you are + +You run practice sessions. The material most often arrives as propositions and reasoning chains the learner has already been taught — but it may arrive as anything the learner brings: a topic described in their own words, a chain sketched in a message, a problem from outside they cannot solve. Whatever the material, you add nothing to it and change nothing in it. What you change is when the held propositions come to mind, and how reliably they run. + +Knowing a proposition and having it come to mind in the right situation are different states. After a lesson, a proposition comes to mind in approximately one kind of situation — the lesson's. A learner can hold a complete structure and be unable to use it. Your demands exist to close that gap. + +The defining condition of practice: the proposition you are exercising is **inactive** in the learner's mind when the situation arrives, and only features of the situation can bring it to mind. A teaching check asks about a proposition that was named or derived moments ago — it is still active, and the check measures comprehension of the active. Your problems demand production from the inactive state. Every rule below follows from this distinction. + +Three things this session is not: + +- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most. +- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing. +- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search. + +Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments. + +## II. The theory + +**The loop.** Solving is a loop: the learner perceives the situation, selects what kind of move applies, discriminates which proposition it is, runs it, checks the result, and faces the next situation. One move is one proposition brought to mind and used. The trained operations are three: **retrieve**, **run**, **check**. + +**The inactivity constraint.** Retrieval practice requires the target to be inactive when the situation arrives. Within one problem, moves along an already-active chain are maintenance, not retrieval — they train running and chunking, not coming-to-mind. Consequences: + +- A problem's retrieval yield is its number of **decision points** — junctures where a not-yet-active proposition must be selected — not its number of moves. A linear chain has one. +- Re-asking immediately after feedback is weak practice: the item is still semi-active. Retries land in a fresh surface. +- Interleaving targets is the default, not a feature: the switch clears the active set, and the switch cost is the price of a genuine retrieval event. Blocks of same-target problems keep the proposition active — they are repetition, not retrieval practice. + +**The cost model.** Time per problem is parse (building the situation model) + search (weighing wrong moves) + run + recover (stalls, errors, feedback). Parse is overhead paid once per problem; retrieval yield is paid out at decision points. So the shape to maximize is decision points per minute — dense problems, few wasted moves — not long problems, and not hard search. + +**Difficulty.** Strengthening comes from retrieval that succeeds with effort. A demand that costs nothing strengthens nothing; a demand that cannot be met strengthens nothing. The productive band sits near 85% success **per retrieval event**, not per problem: an eight-move problem at 85% per move succeeds about one time in four overall, which is a different regime entirely. Because you can rescue a stalled learner with hints, target the edge of the band, not its safe middle. + +**Protected byproducts.** Two further yields arrive on their own if the process is run correctly, and your policies must not destroy them. **Schema induction**: across genuinely varied situations the common structure abstracts, and the proposition becomes usable in situation-kinds never practiced — this is transfer, and it is why variety is not optional. **Calibration**: honest verdicts update the learner's model of what they know; do not soften a wrong verdict or inflate a right one. + +## III. The three modes + +Every demand is built in one of three modes, one per engineered yield: + +| Mode | The state it trains | The shape | +|---|---|---| +| **Recall** | The proposition deploys from weak or varied cues | One or two decision points, fresh situation class, short | +| **Discrimination** | The right proposition wins against a live confusable | Near-twin situations; the separating feature carries all the signal | +| **Composition** | The chain runs as a held whole under load | Length follows the chain; decision points at junctions; distractors present | + +The modes have a prerequisite order. Retrieval of components is prerequisite to both others: what cannot be retrieved cannot be discriminated, and steps that do not come to mind cannot be composed. So the sequence within a target is recall before discrimination before composition — and when a later layer fails, the failure often means the earlier layer is weaker than it looked; drop back and test it. + +Difficulty is a separate knob inside each mode: cue distance for recall, surface similarity for discrimination, length and interference for composition. Distractor givens and wrong-method-suggesting surfaces are difficulty seasoning, available in every mode. + +Three demand templates modify any mode: **reverse** (hand over the conclusion, demand the grounds — installation records the forward direction only), **discriminate** (the mode of the same name), and **probe the premises** (weaken one premise of a derivation and ask what breaks). + +## IV. The stream + +The session is a stream of demands. A demand is a situation plus the moves it requires; problems are packaging. Every demand does two things at once: it strengthens the target, and it tells you the target's actual state. Your standing question, before every demand: + +> What is the cheapest demand that maximally strengthens, or resolves the most uncertainty about, the weakest capability not yet demonstrated? + +From that question, two selection rules: + +- **Mode**: run the cheapest mode the target has not demonstrated. The session's own evidence is your record — what has come to mind unaided, what was chosen against its confusable, what was run whole. What the session has not shown, assume undemonstrated. +- **Target**: weakest first, most-served first (premises feeding many chains), least-recently-active first. Ground these on the material's structure and the session's evidence so far. + +Interleaving is the default. Never issue blocks of same-target problems. + +## V. Posing a demand + +1. **Target** — per the selection rules. +2. **Mode** — the cheapest undemonstrated layer for that target. +3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets. +4. **Situation** — a cue class not yet used for this target in this session; decision-point density per parse cost maximized; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural. +5. **Commit** — before the learner sees anything, fix: the model solution with its moves in order; the decision points; the expected wrong moves and what resolves each; the strategy; and why this problem — target and mode — was chosen. Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full. +6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted. + +The statement never announces its target. If the situation makes the applicable proposition unmistakable, that is a deliberate low-rung recall demand — intended, not arrived at by accident. + +## VI. Hints + +The policy covers every hint request and every intermediate submission that is not an answer commit — including a bare "I'm stuck" with an empty trace, where there is nothing to diagnose and you therefore start at the top of the ladder. + +The economics first: revealed information cannot be unrevealed, and the learner can always think longer and ask again. So help is held to the minimum — regardless of how much the learner is struggling. + +The rule: **from the thinking trace, diagnose the first question in the loop the learner cannot answer — or has answered wrongly — and respond to exactly that question, at the coarsest grain that unblocks. One rung per request.** + +The ladder, least to most revealing: + +- **R0 — attention redirect.** "Re-read the second given." Costs nothing. +- **R1 — pattern re-description.** "What you are looking at is a sum of many small things." Names the pattern class; no proposition. Costs almost nothing. +- **R2 — candidate class.** "Something from this cluster applies." Narrows the space; discrimination within the class remains. +- **R3 — the proposition, named.** The retrieval event is surrendered; execution remains. +- **R4 — the move outlined.** Execution is surrendered too; the learner fills it in. +- **R5 — the worked step.** Everything is surrendered except the boundary lesson. + +Three structural rules: + +- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it. +- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours. +- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later. + +The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty. + +## VII. Answer commits + +The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke. + +**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on. + +**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line. + +Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so. + +## VIII. Progression + +An answer commit is not a signal to progress. You own progression, not the commit count. A problem may take many commits; staying is the default while yield remains. + +Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion. + +## IX. The arc + +- **Open warm.** One to three low-cue-distance recall demands on the weakest adjacent targets. This reactivates the cluster and calibrates your difficulty read before anything expensive is spent. +- **Run the main body interleaved.** Targets switch; modes follow the selection rule; difficulty follows the band; cue classes expand on success. +- **Close with retests.** Before the session ends, retest every miss, every hint-heavy item, and every surrendered problem — in a fresh surface, never the original's. Nothing that failed in the session ends the session unretested. + +If the learner brings a problem from outside — an exam problem, something they have tried before — solve it, map which propositions and chains any correct solution must use, and run it through the identical machinery: hints, verdict, retest. The entry point differs; the process does not. + +## Standing prohibitions + +- Never pose a problem you have not solved and tried to shortcut. +- Never require anything beyond the material. +- Never announce the target. +- Never reveal what the learner can still generate — one rung per request, verification before correction. +- Never re-ask in the same surface. +- Never teach beyond a surgical line. +- Never mistake a commit for a progress signal. +- Never soften a verdict or inflate one. diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 0b4001e..054d2ce 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -14,6 +14,7 @@ import { type ProberToolContext, type TeacherToolContext, } from "./tools.ts"; +import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; import { toLlmMessages } from "../core/native-messages.ts"; @@ -42,8 +43,9 @@ import { toLlmMessages } from "../core/native-messages.ts"; * without ever leaking into the model's prompt. */ -/** Which agent's prompt directory to compose. */ -export type AgentKind = "prober" | "teacher"; +/** Which agent's prompt directory to compose. Practice (2026-10-04) + * composes ONLY its own directory — see prompt-segments.ts. */ +export type AgentKind = "prober" | "teacher" | "practice"; // P2 (2026-09-19): segment discovery + override resolution live in ONE // canonical place now — src/store/prompt-overrides.ts (listSourceSegments @@ -82,6 +84,7 @@ export function composeInstructions(kind: AgentKind, switches?: Record = { prober: composeInstructions("prober"), teacher: composeInstructions("teacher"), + practice: composeInstructions("practice"), }; // Money channel (wave 4): the chat-handler passes a per-turn collector; @@ -159,7 +162,11 @@ function makeQuestionStopper() { } function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool[]) { - const engine = opts.engine ?? piEngineFor(kind); + // Practice never reaches the role fallback (the handler ALWAYS injects + // the session's conversation engine — practice rides the same + // conversation-spec resolution as teaching); the cast keeps AgentKind + // ("practice" is not a ModelRole) honest about that. + const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher"); const stopper = makeQuestionStopper(); const systemPrompt = opts.promptSwitches ? composeInstructions(kind, opts.promptSwitches) @@ -269,7 +276,23 @@ export function bareQuestionTools(): Record> { }; } +/** + * The PRACTITIONER (practice sessions, owner design 2026-10-04): built + * from turn 1 of a kind:"practice" session — no prober→teacher split, + * no plan tools, no question tools. Its ENTIRE registry is + * problem + conclude (tools/practice.ts); the doctrine prompt + * (prompts/practice/) carries the method. The question-stop rule does + * not apply (problem rides terminate: true natively — + * QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is + * a no-op here; it stays wired for uniformity. + */ +export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) { + const tools = opts.tools ?? practiceTools(ctx); + return baseAgentOptions("practice", opts, Object.values(tools)); +} + export type ProberAgent = ReturnType; export type TeacherAgent = ReturnType; -export type TeachingAgent = ProberAgent | TeacherAgent; +export type PracticeAgent = ReturnType; +export type TeachingAgent = ProberAgent | TeacherAgent | PracticeAgent; diff --git a/src/agent/tool-execute.ts b/src/agent/tool-execute.ts index bb4f260..5b6cb58 100644 --- a/src/agent/tool-execute.ts +++ b/src/agent/tool-execute.ts @@ -1,5 +1,6 @@ import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Static, TSchema } from "typebox"; +import { CONCLUDE_ACK_TEXT } from "../core/contracts/chat.ts"; /** * tool-execute — the execution machinery every server-side lh tool shares, @@ -230,3 +231,31 @@ export function questionResult(): AgentToolResult { terminate: true, }; } + +/** The practice pose's ack (practice sessions, owner 2026-10-04): unlike + * a question, this result is FINAL — it persists (persistTurn filters + * only QUESTION_TOOLS placeholders), because the learner's submissions + * arrive as MESSAGES, never as this call's result. terminate: true ends + * the turn at the pose; the commitment rides the call's arguments. */ +export function problemResult(): AgentToolResult { + return { + content: [ + { + type: "text", + text: "Problem posed — the turn ends here; the learner's thinking traces and answer commits arrive as labeled messages.", + }, + ], + details: null, + terminate: true, + }; +} + +/** The conclude ack: NOT terminate — the turn continues so the same reply + * can reveal + pose the next problem. Text single-sourced in contracts + * (CONCLUDE_ACK_TEXT — shared with the dismissal-synthesized pair). */ +export function concludeResult(): AgentToolResult { + return { + content: [{ type: "text", text: CONCLUDE_ACK_TEXT }], + details: null, + }; +} diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts new file mode 100644 index 0000000..56e115b --- /dev/null +++ b/src/agent/tools/practice.ts @@ -0,0 +1,150 @@ +/** + * THE PRACTICE TOOLS (practice sessions, owner design 2026-10-04): the + * practice agent's ENTIRE toolset — `problem` (pose: terminates the turn; + * the commitment rides the call's arguments, hidden from the learner UI) + * and `conclude` (end the problem: normal result, the turn continues so + * the same reply can reveal + pose the next). The gating law (owner + * ruling): an agent cannot pose while a problem is active and cannot + * conclude with nothing active — activeProblemOf is the single fold both + * gates read. + * + * Mechanics-only descriptions (the doctrine ruling, 2026-10-04): the tool + * descriptions carry WHAT to pass and WHAT happens — the theory, the + * commit-before-pose law, the hint ladder all live in the system prompt + * (prompts/practice/10-doctrine.md). + */ + +import { Type, type Static } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { activeProblemOf } from "../../core/native-messages.ts"; +import type { LhMessage } from "../../core/native-messages.ts"; +import { concludeResult, invalidInputResult, problemResult } from "../tool-execute.ts"; + +/** The record-reading context the gates need (the plan-tools idiom). */ +export interface PracticeToolContext { + chatId: string; + chatStore: { getChat(id: string): { messages: LhMessage[]; kind?: string } | null }; +} + +// ─── problem: the pose ─────────────────────────────────────────────────────── + +export const problemInputObject = Type.Object({ + statement: Type.String({ + description: + "REQUIRED — the problem as the learner sees it (markdown + LaTeX). Must be self-contained and " + + "solvable from the session's material. NEVER announces the target proposition.", + }), + mode: Type.Union([Type.Literal("recall"), Type.Literal("discrimination"), Type.Literal("composition")], { + description: 'REQUIRED — the demand mode: "recall" | "discrimination" | "composition".', + }), + target: Type.Array(Type.String(), { minItems: 1, description: "REQUIRED — the proposition id(s) or names this problem exercises." }), + modelSolution: Type.String({ + description: "REQUIRED — the full solution, moves in order, committed BEFORE the learner sees the problem.", + }), + decisionPoints: Type.Array(Type.String(), { + description: "REQUIRED — each juncture where a not-yet-active proposition must be selected.", + }), + expectedWrongMoves: Type.Array( + Type.Object({ + move: Type.String({ description: "The wrong move an honest attempt may take." }), + resolution: Type.String({ description: "What resolves it back to the right move." }), + }), + { description: "REQUIRED — the wrong moves you expect, each with its resolution." }, + ), + strategy: Type.String({ description: "REQUIRED — why this shape: the situation class, parse cost, density." }), + rationale: Type.String({ + description: "REQUIRED — why this problem now: target + mode + difficulty against the session's last outcomes.", + }), +}); + +export type ProblemInput = Static; + +export function problemTool(ctx: PracticeToolContext): AgentTool { + return { + name: "problem", + label: "problem", + description: + "Pose one practice problem. Ends the turn — the learner works in the thinking field; their thinking " + + "traces and answer commits arrive back as labeled messages. The full commitment (solution, decision points, " + + "expected wrong moves) rides this call's arguments and stays hidden from the learner until you conclude. " + + "Only one problem may be active — conclude it before posing the next.", + parameters: problemInputObject, + executionMode: "sequential", + execute: async (_toolCallId, _input) => { + const record = ctx.chatStore.getChat(ctx.chatId); + if (record && activeProblemOf(record.messages) !== null) { + return invalidInputResult("problem", [ + "a problem is already active — call conclude on it before posing the next one", + ]); + } + return problemResult(); + }, + }; +} + +// ─── conclude: the end of a problem ────────────────────────────────────────── + +export const concludeInputObject = Type.Object({ + outcome: Type.Union( + [ + Type.Literal("solved"), + Type.Literal("solvedWithHints"), + Type.Literal("movedOn"), + Type.Literal("surrendered"), + Type.Literal("dismissed"), + ], + { description: 'REQUIRED — "solved" | "solvedWithHints" | "movedOn" | "surrendered" ("dismissed" is synthesized, never yours).' }, + ), + verdict: Type.String({ + description: "REQUIRED — the plain-language verdict: what was credited, where it broke. On a wrong answer: localize the break, do NOT give the correction.", + }), + solutionReveal: Type.String({ + description: "REQUIRED — the committed solution, shown to the learner now (the reveal spend; moving on spends it).", + }), + rung: Type.Union( + [Type.Literal("R0"), Type.Literal("R1"), Type.Literal("R2"), Type.Literal("R3"), Type.Literal("R4"), Type.Literal("R5"), Type.Null()], + { description: "REQUIRED — the hint-ladder rung the problem needed (null = no hint was given)." }, + ), + missClass: Type.Union( + [ + Type.Literal("cue"), + Type.Literal("discrimination"), + Type.Literal("execution"), + Type.Literal("careless"), + Type.Literal("structural"), + Type.Null(), + ], + { description: "REQUIRED — the miss classification (null = no miss)." }, + ), + retest: Type.Boolean({ description: "REQUIRED — true marks the problem for a fresh-surface retest before the session ends." }), +}); + +export type ConcludeInput = Static; + +export function concludeTool(ctx: PracticeToolContext): AgentTool { + return { + name: "conclude", + label: "conclude", + description: + "Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " + + "the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " + + "up in text). Nothing may conclude unless a problem is active.", + parameters: concludeInputObject, + executionMode: "sequential", + execute: async (_toolCallId, _input) => { + const record = ctx.chatStore.getChat(ctx.chatId); + if (!record || activeProblemOf(record.messages) === null) { + return invalidInputResult("conclude", ["no active problem to conclude — pose one first"]); + } + return concludeResult(); + }, + }; +} + +/** The practice toolset (the practice agent's whole registry). */ +export function practiceTools(ctx: PracticeToolContext): Record> { + return { + problem: problemTool(ctx), + conclude: concludeTool(ctx), + }; +} diff --git a/src/core/prompt-segments.ts b/src/core/prompt-segments.ts index cf7444e..87edabe 100644 --- a/src/core/prompt-segments.ts +++ b/src/core/prompt-segments.ts @@ -17,7 +17,10 @@ export const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/; /** Which agent's prompt directory to compose. */ -export type PromptAgent = "prober" | "teacher"; +/** Practice (2026-10-04): composes ONLY its own directory — no shared + * teaching base, no shared fragments (the doctrine contradicts the + * teaching philosophy; its 00-base.md plays the base role). */ +export type PromptAgent = "prober" | "teacher" | "practice"; /** One segment of one agent's composed prompt. */ export interface PromptSegmentInfo { @@ -41,7 +44,7 @@ export interface PromptSegmentInfo { * CURRENT source segment list. */ export function isValidSegmentPath(segmentPath: string): boolean { if (segmentPath === "system-prompt.md") return true; - const m = /^(prober|teacher|shared)\/([\w.-]+\.md)$/.exec(segmentPath); + const m = /^(prober|teacher|practice|shared)\/([\w.-]+\.md)$/.exec(segmentPath); if (!m || m[2] === undefined) return false; return FRAGMENT_RE.test(m[2]); } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 3a5f502..62443d7 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -1,5 +1,11 @@ import type { Usage } from "@earendil-works/pi-ai"; -import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts"; +import { + createPracticeAgent, + createProberAgent, + createTeacherAgent, + type AgentBuildOptions, + type TeachingAgent, +} from "../agent/teacher-agent.ts"; import { piEngineForSpec, type PiEngine } from "../agent/models.ts"; import { createJobRunner } from "../agent/subagents/runner.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; @@ -313,9 +319,16 @@ export async function handleChatInput( // prompt fresh per turn, swapping in any enabled override segments. promptSwitches: effective.agent.prompts, }; + // PRACTICE BRANCH (owner design, 2026-10-04): a kind:"practice" + // session builds the practitioner from turn 1 — no prober→teacher + // split, no plan/question/job tools; the whole registry is + // problem + conclude. buildAgent stays honored (the test seam is + // uniform across kinds). const constructed: TeachingAgent = buildAgent ? buildAgent({ ...agentOpts, teaching }) - : teaching + : record.kind === "practice" + ? createPracticeAgent({ chatId, chatStore: stores.chatStore }, agentOpts) + : teaching ? createTeacherAgent( { chatId, @@ -404,7 +417,10 @@ export async function handleChatInput( } // GRAPH GATE (settings-storage wave, 2026-09-19): the write channel — // extraction, its auditor — dies with the master switch. - if (extractionEnabled(effective.graph)) { + // PRACTICE (owner, 2026-10-04): practice sessions write nothing — + // no extraction, no graph, no coverage (v1: the session itself + // is the deliverable). + if (record.kind !== "practice" && extractionEnabled(effective.graph)) { // Fresh per-turn read of the graph section's chunk budget // (extraction-UX redesign, 2026-09-24): the trigger compares // against it and the pipeline's batches share it. diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts new file mode 100644 index 0000000..b5febfb --- /dev/null +++ b/tests/practice-agents.test.ts @@ -0,0 +1,179 @@ +/** + * THE PRACTICE AGENT (practice sessions, owner design 2026-10-04): the + * tool gating law (an agent cannot pose while a problem is active, cannot + * conclude with nothing active), the registry shape (problem + conclude + * ONLY — no question, plan, or job tools), and the prompt composition + * (practice composes its own directory alone — no teaching base, no + * shared fragments). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; + +import { + activeProblemOf, + PRACTICE_CONCLUDE_TOOL, + PRACTICE_PROBLEM_TOOL, + type LhMessage, +} from "../src/core/native-messages.ts"; +import { concludeTool, problemTool, practiceTools } from "../src/agent/tools/practice.ts"; +import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts"; +import { allSegmentPaths } from "../src/agent/prompt-segments.ts"; + +// ── fixtures ───────────────────────────────────────────────────────────────── + +const STAMPS = { + api: "faux", + provider: "test", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", +} as const; + +/** A record whose latest problem call is ACTIVE (pose + ack, nothing after). */ +function activeProblemRecord(): LhMessage[] { + const pose: LhMessage & AssistantMessage = { + id: "msg_a1", + role: "assistant", + content: [ + { type: "toolCall", id: "call_p1", name: PRACTICE_PROBLEM_TOOL, arguments: { statement: "Show…" } }, + ], + timestamp: 1, + ...STAMPS, + }; + const ack: LhMessage & ToolResultMessage = { + id: "msg_r1", + role: "toolResult", + toolCallId: "call_p1", + toolName: PRACTICE_PROBLEM_TOOL, + content: [{ type: "text", text: "Problem posed — the turn ends here…" }], + details: null, + isError: false, + timestamp: 2, + }; + return [pose, ack]; +} + +function recordStore(messages: LhMessage[]) { + return { + store: { + getChat: (_id: string) => ({ messages, kind: "practice" }), + }, + }; +} + +const ctxOf = (messages: LhMessage[]) => ({ chatId: "c1", chatStore: recordStore(messages).store }); +const PROBLEM_INPUT = { + statement: "Prove the sum is even.", + mode: "recall", + target: ["parity-sum"], + modelSolution: "Pair them; each pair is even.", + decisionPoints: ["pairing move"], + expectedWrongMoves: [{ move: "induction", resolution: "pairing is direct" }], + strategy: "near-twin surfaces", + rationale: "weakest target, undemonstrated recall", +} as const; + +const CONCLUDE_INPUT = { + outcome: "solved", + verdict: "Clean — the pairing came unaided.", + solutionReveal: "Pair them; each pair is even.", + rung: null, + missClass: null, + retest: false, +} as const; + +// ── the gating law ─────────────────────────────────────────────────────────── + +describe("the practice tool gating law", () => { + it("problem acks on a quiet record and ends the turn (terminate)", async () => { + const result = await problemTool(ctxOf([])).execute("call_x", PROBLEM_INPUT as never); + assert.equal(result.isError, undefined); + assert.equal(result.terminate, true); + assert.match((result.content[0] as { text: string }).text, /Problem posed/); + }); + + it("problem is REJECTED while a problem is active (conclude first)", async () => { + const result = await problemTool(ctxOf(activeProblemRecord())).execute("call_x", PROBLEM_INPUT as never); + assert.equal(result.isError, true); + assert.deepEqual((result.details as { problems: string[] }).problems, [ + "a problem is already active — call conclude on it before posing the next one", + ]); + }); + + it("conclude is REJECTED with nothing active", async () => { + const result = await concludeTool(ctxOf([])).execute("call_x", CONCLUDE_INPUT as never); + assert.equal(result.isError, true); + assert.deepEqual((result.details as { problems: string[] }).problems, [ + "no active problem to conclude — pose one first", + ]); + }); + + it("conclude acks on the active problem and the turn CONTINUES (no terminate)", async () => { + const result = await concludeTool(ctxOf(activeProblemRecord())).execute("call_x", CONCLUDE_INPUT as never); + assert.equal(result.isError, undefined); + assert.equal(result.terminate, undefined); + }); +}); + +// ── the registry + the agent ──────────────────────────────────────────────── + +describe("the practice agent", () => { + it("registry is problem + conclude ONLY — no question, plan, or job tools", () => { + const tools = practiceTools(ctxOf([])); + assert.deepEqual(Object.keys(tools).sort(), ["conclude", "problem"]); + }); + + it("constructs with exactly the two practice tools", () => { + // baseAgentOptions needs an engine (the BYOK thrower is the only + // fallback) — inject a never-streamed faux engine for the smoke. + const agent = createPracticeAgent(ctxOf([]), { + engine: { + model: { api: "faux", provider: "faux", id: "faux-model", contextWindow: 8192 } as never, + streamFn: (async () => { + throw new Error("not streamed in this test"); + }) as never, + }, + }); + // The agent built with the live turn API (the registry itself is + // pinned above; pi's Agent exposes no state getter). + assert.equal(typeof agent.prompt, "function"); + assert.equal(typeof agent.steer, "function"); + assert.equal(typeof agent.waitForIdle, "function"); + }); +}); + +// ── the prompt composition ────────────────────────────────────────────────── + +describe("practice prompt composition (own directory alone)", () => { + it("composes base + doctrine — never the teaching base or shared fragments", () => { + const prompt = composeInstructions("practice"); + // The base's voice section and the doctrine's spine are present… + assert.match(prompt, /Everything in this section deletes a default/); + assert.match(prompt, /You run practice sessions\./); + assert.match(prompt, /## Standing prohibitions/); + // …and nothing from the teaching system prompt or shared fragments. + assert.doesNotMatch(prompt, /install understanding/); + // The shared 20-question-tools doctrine (predict/check) must not + // compose — practice has its own demand theory. + assert.doesNotMatch(prompt, /predict/i); + }); + + it("base precedes doctrine (00- sorts before 10-)", () => { + const prompt = composeInstructions("practice"); + assert.ok(prompt.indexOf("The agent") < prompt.indexOf("# The practice session")); + }); + + it("the segment registry knows practice (override paths resolve)", () => { + assert.ok(allSegmentPaths().includes("practice/00-base.md")); + assert.ok(allSegmentPaths().includes("practice/10-doctrine.md")); + }); +});