practice agent: problem + conclude tools, doctrine prompt, handler branch

- 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).
This commit is contained in:
amosblomqvist
2026-10-04 21:47:23 +03:00
parent eb7454900c
commit 4bea0b46df
9 changed files with 592 additions and 13 deletions
+150
View File
@@ -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<typeof problemInputObject>;
export function problemTool(ctx: PracticeToolContext): AgentTool<typeof problemInputObject> {
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<typeof concludeInputObject>;
export function concludeTool(ctx: PracticeToolContext): AgentTool<typeof concludeInputObject> {
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<string, AgentTool<any>> {
return {
problem: problemTool(ctx),
conclude: concludeTool(ctx),
};
}