From 0dfb1de2465be1f70da40617828d1bef33c1485d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 06:29:02 +0300 Subject: [PATCH] =?UTF-8?q?practice:=20pose-time=20target=20validation=20?= =?UTF-8?q?=E2=80=94=20names=20normalize=20to=20ids,=20unknowns=20rejected?= =?UTF-8?q?=20(round=209=20slice=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit problemTool takes the whole-graph node source (chat-handler passes graphStore.readNodes, read fresh per pose): prepareArguments silently normalizes name targets to canonical ids so the RECORD carries ids (the mastery trace groups on them); execute rejects unresolvable targets with a one-line invalidInputResult — no re-listing, the [id] proposition list already sits in the agent's context. Validation set is the whole graph, not the picks: pickless sessions and future graph-tools poses stay valid. Absent node source (tests) = no validation. --- next-env.d.ts | 4 +-- src/agent/tools/practice.ts | 50 +++++++++++++++++++++++++++++++++---- src/server/chat-handler.ts | 13 +++++++++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts index fe15148..e5e6f18 100644 --- a/src/agent/tools/practice.ts +++ b/src/agent/tools/practice.ts @@ -16,6 +16,7 @@ import { Type, type Static } from "typebox"; import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { resolveTargetId } from "../../core/chains.ts"; import { activeProblemOf } from "../../core/native-messages.ts"; import type { LhMessage } from "../../core/native-messages.ts"; import { concludeResult, invalidInputResult, problemResult } from "../tool-execute.ts"; @@ -109,6 +110,11 @@ export function healEncodedArguments( export interface PracticeToolContext { chatId: string; chatStore: { getChat(id: string): { messages: LhMessage[]; kind?: string } | null }; + /** The WHOLE-graph node list, read fresh per pose (owner round 9 — the + * mastery-trace contract): targets validate against every proposition, + * not just the session's picks, so pickless sessions and future + * graph-tools poses stay valid. Absent (tests) = no validation. */ + graphNodes?: () => ReadonlyArray>; } /** TURN-SCOPED GATE STATE (the BKT incident, 2026-10-05): the gates used @@ -143,7 +149,12 @@ export const problemInputObject = Type.Object({ 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." }), + target: Type.Array(Type.String(), { + minItems: 1, + description: + "REQUIRED — the proposition id(s) this problem exercises, from the session context's [id] list. " + + "Names are normalized to ids at the seam; an unknown proposition is rejected.", + }), modelSolution: Type.String({ description: "REQUIRED — the full solution, moves in order, committed BEFORE the learner sees the problem. This IS the reveal — conclude shows it verbatim; never re-author it.", }), @@ -151,7 +162,10 @@ export const problemInputObject = Type.Object({ export type ProblemInput = Static; -export function problemTool(gate: PracticeGate): AgentTool { +export function problemTool( + gate: PracticeGate, + graphNodes?: PracticeToolContext["graphNodes"], +): AgentTool { return { name: "problem", label: "problem", @@ -162,13 +176,39 @@ export function problemTool(gate: PracticeGate): AgentTool healEncodedArguments(args, problemInputObject) as never, - execute: async (_toolCallId, _input) => { + prepareArguments: (args: unknown) => { + const healed = healEncodedArguments(args, problemInputObject); + // TARGET NORMALIZATION (owner round 9): names silently become ids — + // the RECORD carries canonical ids (the mastery trace groups on + // them); unresolvable strings pass through for execute to reject. + if (!graphNodes) return healed as never; + const target = (healed as { target?: unknown }).target; + if (!Array.isArray(target)) return healed as never; + const list = graphNodes(); + const normalized = target.map((t) => + typeof t === "string" ? (resolveTargetId(t, list) ?? t) : t, + ); + if (normalized.every((t, i) => t === target[i])) return healed as never; + return { ...(healed as Record), target: normalized } as never; + }, + execute: async (_toolCallId, input) => { if (gate.problemActive) { return invalidInputResult("problem", [ "a problem is already active — call conclude on it before posing the next one", ]); } + // TARGET VALIDATION (owner round 9): against the WHOLE graph. A + // one-line rejection — the proposition list already sits in the + // agent's context (the [id] marker); no re-listing. + if (graphNodes) { + const list = graphNodes(); + const unknown = input.target.filter((t) => resolveTargetId(t, list) === null); + if (unknown.length > 0) { + return invalidInputResult("problem", [ + `target ${JSON.stringify(unknown)} is not a known proposition — targets must be proposition ids from the session context`, + ]); + } + } gate.problemActive = true; return problemResult(); }, @@ -237,7 +277,7 @@ export function practiceTools(ctx: PracticeToolContext): Record stores.graphStore.readNodes(), + }, + agentOpts, + ) : teaching ? createTeacherAgent( {