diff --git a/src/agent/subagents/demo-builder.ts b/src/agent/subagents/demo-builder.ts index 8788cfb..8be94cd 100644 --- a/src/agent/subagents/demo-builder.ts +++ b/src/agent/subagents/demo-builder.ts @@ -6,6 +6,7 @@ import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; import type { DemoResult } from "../../core/contracts.ts"; import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts"; +import { healToolRecord } from "../tools/heal.ts"; /** * The demo-builder sub-agent (vision §2 interactive demos + §2.5 "sub-agents @@ -99,7 +100,7 @@ export function createDemoBuilderTools( headless: HeadlessApi, onActivity?: (line: string) => void, ): Record> { - return { + return healToolRecord({ write_demo: { name: "write_demo", label: "write_demo", @@ -141,7 +142,7 @@ export function createDemoBuilderTools( return formatRunResult(result); }), }, - }; + }); } function summarizeCounts(result: DemoRunResult): string { diff --git a/src/agent/subagents/submit-agent.ts b/src/agent/subagents/submit-agent.ts index ac5ce04..b1ccb67 100644 --- a/src/agent/subagents/submit-agent.ts +++ b/src/agent/subagents/submit-agent.ts @@ -14,6 +14,8 @@ import { invalidInputResult, toToolResult } from "../tool-execute.ts"; import type { ModelRole } from "../../../config.ts"; import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; +import { healToolRecord } from "../tools/heal.ts"; +import { healEncodedArguments } from "../../core/argument-heal.ts"; // ── the weak-model string-heal (owner, 2026-09-28; moved here from // extraction.ts with the digest-aware redesign, 2026-09-30 — the heal @@ -78,14 +80,23 @@ export async function runSubmitAgent(opts: { let submitted: T | undefined; const result = await runSubagent( [ - ...(opts.extraTools ? Object.values(opts.extraTools) : []), + // healToolRecord (owner, 2026-10-09): the standard argument heal + // over the extra tools (the auditor's graph set) — same as every + // registry. + ...(opts.extraTools ? Object.values(healToolRecord(opts.extraTools)) : []), { name: opts.toolName, label: opts.toolName, description: opts.toolDescription, parameters: opts.parameters, prepareArguments: (args) => - (opts.prepareArguments ? opts.prepareArguments(args) : args) as never, + // The standard heal composes AFTER the caller's shim (the + // whitehead generalization, 2026-10-09) — extraction's + // healStringifiedArrays stays the inner pass. + healEncodedArguments( + opts.prepareArguments ? opts.prepareArguments(args) : args, + opts.parameters, + ) as never, execute: async (_toolCallId, input) => { const value = opts.fill(input); const problem = opts.validate?.(value) ?? null; diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 054d2ce..0aca5c8 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -15,6 +15,7 @@ import { type TeacherToolContext, } from "./tools.ts"; import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; +import { healToolRecord } from "./tools/heal.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"; @@ -270,10 +271,13 @@ export function bareQuestionTools(): Record> { return questionResult(); }, }; - return { + // healToolRecord (owner, 2026-10-09 — the whitehead generalization): + // the bare quiz carried NO prepareArguments at all — not even the + // options-unwrap; now it gets the full standard heal like every tool. + return healToolRecord({ quiz, ask_user_question: askUserQuestionTool, - }; + }); } /** diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 4028771..576f047 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -23,6 +23,7 @@ import { readTool } from "./tools/read.ts"; import { lsTool } from "./tools/ls.ts"; import { findTool } from "./tools/find.ts"; import { grepTool } from "./tools/grep.ts"; +import { healToolRecord } from "./tools/heal.ts"; import type { GraphStore } from "../core/ports.ts"; export { jobTools }; @@ -246,7 +247,10 @@ export function createProberTools( // unusable as Record> downstream). const quizzing: Record> = mechanics === "free" ? { explain: probeExplainTool } : { quiz: probeQuizTool }; - return { + // healToolRecord (owner, 2026-10-09 — the whitehead generalization): + // the standard argument heal over the WHOLE registry — every tool, + // not just the one the latest wobble incident landed on. + return healToolRecord({ ...filterEnabledTools( { ...quizzing, @@ -274,10 +278,10 @@ export function createProberTools( : {}), ...done, }, - withoutMechanics(enabled), - ), - ...done, - }; + withoutMechanics(enabled), + ), + ...done, + }); } // ─── the teacher's question tools (quiz carries the position field) ────────── @@ -378,8 +382,10 @@ export function createTeacherTools( // two differently-typed literals collapses into an unusable union type. const quizzing: Record> = mechanics === "free" ? { explain: teachExplainToolFor(ctx) } : { quiz: teachQuizToolFor(ctx) }; - return filterEnabledTools( - { + // healToolRecord — same standard as the prober's registry (above). + return healToolRecord( + filterEnabledTools( + { ...quizzing, ask_user_question: askUserQuestionTool, // The learner-filesystem set is always-on (user-decided 2026-09, @@ -398,5 +404,6 @@ export function createTeacherTools( ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }), }, withoutMechanics(enabled), + ), ); } diff --git a/src/agent/tools/heal.ts b/src/agent/tools/heal.ts new file mode 100644 index 0000000..0e669ee --- /dev/null +++ b/src/agent/tools/heal.ts @@ -0,0 +1,48 @@ +/** + * THE UNIVERSAL ARGUMENT HEAL — the agent-boundary wiring (owner, + * 2026-10-09 — generalize the 2026-10-04 practice heal). + * + * Every weak-model argument wobble incident produced a heal wired ONLY + * to the tool where it landed — parseOptionsIfString (questions, + * 2026-09-26), healStringifiedArrays (extraction), + * healEncodedArguments (practice, 2026-10-04) — and the whitehead death + * loop (2026-10-09: quiz `type: "\"predict\""` rejected ten times, + * byte-identical, in a session the lesson never reached) happened + * precisely because the practice heal never reached the quiz tools. The + * owner ruling: this is ONE standard pattern, applied at EVERY tool — + * heal what is mechanically recognizable (a JSON-encoded stand-in for + * the schema-expected shape; the membership-value debris strip lives in + * core/value-debris), let validation reject the rest honestly. + * + * The pure function lives in core/argument-heal (the display seam needs + * it too — the tool-parts law); this module is the per-tool/registry + * wiring at pi's prepareArguments seam — BEFORE TypeBox validation. + * Well-formed args pass through UNTOUCHED (same reference); heals are + * idempotent, so a per-tool heal composed under the record-level + * wrapper is a harmless second pass. + */ + +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { healEncodedArguments } from "../../core/argument-heal.ts"; + +export { healEncodedArguments }; + +/** Wrap ONE tool with the standard heal: the tool's own prepareArguments + * (options-unwrap, target normalization, …) runs first, then the heal + * against the tool's declared schema. Spread-safe (these are plain + * object literals) and idempotent. */ +export function withArgumentHeal(tool: AgentTool): AgentTool { + const inner = tool.prepareArguments; + return { + ...tool, + prepareArguments: (args: unknown) => healEncodedArguments(inner ? inner(args) : args, tool.parameters), + }; +} + +/** The registry-wide form (the owner's "standard pattern everywhere"): + * wrap every tool in a tool record. Applied at the registry-assembly + * points — createProberTools, createTeacherTools, practiceTools, + * bareQuestionTools, the subagent sets — so no tool can ship unwrapped. */ +export function healToolRecord(tools: Record>): Record> { + return Object.fromEntries(Object.entries(tools).map(([name, tool]) => [name, withArgumentHeal(tool)])); +} diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts index 16ef2e7..755052f 100644 --- a/src/agent/tools/practice.ts +++ b/src/agent/tools/practice.ts @@ -26,91 +26,8 @@ import { proberGraphTools, type GraphToolContext } from "./graph.ts"; import { activeProblemOf } from "../../core/native-messages.ts"; import type { LhMessage } from "../../core/native-messages.ts"; import { concludeResult, invalidInputResult, problemResult } from "../tool-execute.ts"; +import { healEncodedArguments, healToolRecord } from "./heal.ts"; -// ─── the weak-model argument heal ───────────────────────────────────────────────── - -/** One property schema of a TypeBox object (the shapes this fork emits: - * literal unions carry anyOf[{const}], scalars carry type). */ -type PropSchema = { - type?: string; - const?: unknown; - anyOf?: PropSchema[]; -}; - -/** The literal values a property accepts ([] when it is not a literal - * union / bare literal). */ -function literalsOf(prop: PropSchema): unknown[] { - if (prop.anyOf) return prop.anyOf.flatMap((m) => literalsOf(m)); - return "const" in prop ? [prop.const] : []; -} - -/** Does the property accept null (Type.Null standalone or as a union - * member)? */ -function acceptsNull(prop: PropSchema): boolean { - if (prop.type === "null") return true; - return (prop.anyOf ?? []).some((m) => m.type === "null"); -} - -/** JSON.parse that never throws (undefined for malformed input). */ -function tryParse(raw: string): unknown { - try { - return JSON.parse(raw) as unknown; - } catch { - return undefined; - } -} - -/** THE WEAK-MODEL ARGUMENT HEAL (owner, 2026-10-04 — the GLM incident: - * 19 consecutive rejected `problem` calls, every one carrying - * `"mode": "\"recall\""` — a JSON-string-encoded enum — while the - * TypeBox error text `must be equal to constant` never named the expected - * values, so the model re-emitted the identical payload each time). At - * pi's prepareArguments seam — BEFORE validation — the same discipline as - * healStringifiedArrays (extraction) and parseOptionsIfString (questions): - * a STRING value where the schema expects a literal union / boolean / - * null / number / array is JSON.parse-d and kept when the parsed value - * satisfies the expectation. Well-formed args pass through UNTOUCHED - * (same reference); unhealable values stay for validation to reject - * honestly. */ -export function healEncodedArguments( - args: unknown, - schema: { properties?: Record }, -): unknown { - if (typeof args !== "object" || args === null || Array.isArray(args)) return args; - const properties = schema.properties; - if (!properties) return args; - let healed: Record | null = null; - for (const [key, prop] of Object.entries(properties)) { - const raw = (args as Record)[key]; - if (typeof raw !== "string") continue; - const parsed = tryParse(raw); - let fixed: unknown; - const literals = literalsOf(prop); - if (literals.length > 0) { - if (literals.includes(raw)) continue; // already a valid member - if (literals.includes(parsed)) fixed = parsed; - } - // The literal branch does not cover null (Type.Null carries no - // const) — a union of literals ∪ null must also accept "null". - if (fixed === undefined && acceptsNull(prop) && parsed === null) fixed = null; - if (fixed === undefined && prop.type === "boolean" && typeof parsed === "boolean") { - fixed = parsed; - } - if ( - fixed === undefined && - (prop.type === "number" || prop.type === "integer") && - typeof parsed === "number" - ) { - fixed = parsed; - } - if (fixed === undefined && prop.type === "array" && Array.isArray(parsed)) fixed = parsed; - if (fixed !== undefined) { - healed ??= { ...(args as Record) }; - healed[key] = fixed; - } - } - return healed ?? args; -} /** The record-reading context the gates need (the plan-tools idiom). */ export interface PracticeToolContext { @@ -159,7 +76,7 @@ export const problemInputObject = Type.Object({ "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".', + description: "REQUIRED — the demand mode: recall, discrimination, or composition (bare words, no quotes).", }), target: Type.Optional( Type.Array(Type.String(), { @@ -254,14 +171,14 @@ export const concludeInputObject = Type.Object({ Type.Literal("surrendered"), Type.Literal("dismissed"), ], - { description: 'REQUIRED — "solved" | "solvedWithHints" | "movedOn" | "surrendered" ("dismissed" is synthesized, never yours).' }, + { description: "REQUIRED — the outcome: solved, solvedWithHints, movedOn, or surrendered — bare words, no quotes (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.", }), 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)." }, + { description: "REQUIRED — the hint-ladder rung the problem needed: R0 through R5, or null = no hint was given (bare, no quotes)." }, ), missClass: Type.Union( [ @@ -272,7 +189,7 @@ export const concludeInputObject = Type.Object({ Type.Literal("structural"), Type.Null(), ], - { description: "REQUIRED — the miss classification (null = no miss)." }, + { description: "REQUIRED — the miss classification: cue, discrimination, execution, careless, or structural — or null = no miss (bare, no quotes)." }, ), retest: Type.Boolean({ description: "REQUIRED — true marks the problem for a fresh-surface retest before the session ends." }), }); @@ -312,7 +229,11 @@ export function practiceTools(ctx: PracticeToolContext): Record literalsOf(m)); + return "const" in prop ? [prop.const] : []; +} + +/** Does the property accept null (Type.Null standalone or as a union + * member)? */ +function acceptsNull(prop: PropSchema): boolean { + if (prop.type === "null") return true; + return (prop.anyOf ?? []).some((m) => m.type === "null"); +} + +/** JSON.parse that never throws (undefined for malformed input). */ +function tryParse(raw: string): unknown { + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +export function healEncodedArguments( + args: unknown, + schema: { properties?: Record }, +): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) return args; + const properties = schema.properties; + if (!properties) return args; + let healed: Record | null = null; + for (const [key, prop] of Object.entries(properties)) { + const raw = (args as Record)[key]; + if (typeof raw !== "string") continue; + const parsed = tryParse(raw); + let fixed: unknown; + const literals = literalsOf(prop); + if (literals.length > 0) { + if (literals.includes(raw)) continue; // already a valid member + if (literals.includes(parsed)) fixed = parsed; + } + // The literal branch does not cover null (Type.Null carries no + // const) — a union of literals ∪ null must also accept "null". + if (fixed === undefined && acceptsNull(prop) && parsed === null) fixed = null; + if (fixed === undefined && prop.type === "boolean" && typeof parsed === "boolean") { + fixed = parsed; + } + if ( + fixed === undefined && + (prop.type === "number" || prop.type === "integer") && + typeof parsed === "number" + ) { + fixed = parsed; + } + if (fixed === undefined && prop.type === "array" && Array.isArray(parsed)) fixed = parsed; + if (fixed !== undefined) { + healed ??= { ...(args as Record) }; + healed[key] = fixed; + } + } + return healed ?? args; +} diff --git a/src/core/chains.ts b/src/core/chains.ts index 9d23a6c..a82a0eb 100644 --- a/src/core/chains.ts +++ b/src/core/chains.ts @@ -1,4 +1,5 @@ import type { GraphNode } from "./contracts/graph.ts"; +import { stripModelValueDebris } from "./value-debris.ts"; /** * Selection-facing chain views — pure + browser-safe (the shell's chain @@ -63,17 +64,19 @@ export function chainsForSelection( * not unique; an ambiguous name resolves to the FIRST node in store * order). Returns null when nothing matches — the caller rejects. * Validation set is the WHOLE graph, not the session's picks: pickless - * sessions and future graph-tools poses stay valid. */ + * sessions and future graph-tools poses stay valid. The debris strip + * (whitehead, 2026-10-09) heals copy-syntax (`:node-id`, quoted ids) + * before matching — targets are membership values like correctAnswer. */ export function resolveTargetId( target: string, nodes: ReadonlyArray>, ): string | null { - const t = target.trim(); + const t = stripModelValueDebris(target); if (t.length === 0) return null; if (nodes.some((n) => n.id === t)) return t; const lower = t.toLowerCase(); for (const n of nodes) { - if (n.name.trim().toLowerCase() === lower) return n.id; + if (stripModelValueDebris(n.name).toLowerCase() === lower) return n.id; } return null; } diff --git a/src/core/question-core.ts b/src/core/question-core.ts index adb7da4..7e52502 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -18,6 +18,7 @@ */ import { Type, type Static } from "typebox"; +import { stripModelValueDebris } from "./value-debris.ts"; // ─── shared types ─────────────────────────────────────────────────────────── @@ -115,7 +116,9 @@ export function quizInputProblems(input: { }): string[] { const { problems, keys } = optionProblems(input.options); - const entries = coerceCorrectAnswer(input.correctAnswer).map((v) => v.trim()); + // Debris (colons/quotes/whitespace) strips inside coerceCorrectAnswer — + // the shared membership seam (boundary, display, grading). + const entries = coerceCorrectAnswer(input.correctAnswer); const unique = [...new Set(entries)]; if (unique.length === 0 || unique.some((v) => v.length === 0)) { problems.push("correctAnswer is required — provide the value (or label) of the correct option(s)."); @@ -207,10 +210,14 @@ export const QUIZ_QUESTION_TYPES = ["predict", "check"] as const; export type QuizQuestionType = (typeof QUIZ_QUESTION_TYPES)[number]; /** Normalize a persisted quiz `type` (display seam): old records carry - * none, and model wobble degrades to undefined — callers render "Quiz" - * and the check-style card. Only exact literals count. */ + * none, and unhealable wobble degrades to undefined — callers render + * "Quiz" and the check-style card. The debris strip (whitehead, + * 2026-10-09) heals the recorded `"\"predict\""` wobble — a raw + * persisted record displays its intended role. */ export function normalizeQuizType(raw: unknown): QuizQuestionType | undefined { - return raw === "predict" ? "predict" : raw === "check" ? "check" : undefined; + if (typeof raw !== "string") return undefined; + const v = stripModelValueDebris(raw); + return v === "predict" ? "predict" : v === "check" ? "check" : undefined; } /** The reveal law as a pure predicate (owner 2026-10-03): a teach-phase @@ -228,10 +235,10 @@ export const quizInputObject = Type.Object({ ...quizInputBase.properties, type: Type.Union([Type.Literal("predict"), Type.Literal("check")], { description: - "REQUIRED — the question's role in the teaching cycle. \"predict\": posed BEFORE you teach the step " + - "(its own turn; the learner answers, then your teaching resolves it — arrives open, never collapsed). " + - "\"check\": posed AFTER the step's teaching is complete (arrives collapsed behind the \"Check your " + - "understanding\" reveal). ONE question per turn either way.", + "REQUIRED — the question's role in the teaching cycle, as a bare word (no quotes): predict = posed BEFORE " + + "you teach the step (its own turn; the learner answers, then your teaching resolves it — arrives open, never " + + "collapsed); check = posed AFTER the step's teaching is complete (arrives collapsed behind the Check your " + + "understanding reveal). ONE question per turn either way.", }), nodeId: Type.String({ description: @@ -563,19 +570,24 @@ export function shuffleOptions(options: QuizOption[], rand: () => number = Math. // ─── quiz grading ─────────────────────────────────────────────────────────── /** The harness sometimes delivers a multi-select correctAnswer array as a - * JSON-stringified string because the schema union lists String first. */ + * JSON-stringified string because the schema union lists String first. + * Every entry carries the MODEL-VALUE DEBRIS STRIP (whitehead, + * 2026-10-09): glm-5.3 copied option values with the JSON key-value + * colon glued on (`[":parmenides"]`); membership comparisons on ALL + * three seams (boundary check, display parse, grading) route through + * here, so the strip heals new calls AND already-poisoned records. */ export function coerceCorrectAnswer(correctAnswer: string | string[]): string[] { - if (Array.isArray(correctAnswer)) return correctAnswer; + if (Array.isArray(correctAnswer)) return correctAnswer.map(stripModelValueDebris); const trimmed = correctAnswer.trim(); if (trimmed.startsWith("[") && trimmed.endsWith("]")) { try { const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) return parsed.map((v) => String(v)); + if (Array.isArray(parsed)) return parsed.map((v) => stripModelValueDebris(String(v))); } catch { // not valid JSON — treat as a single literal value } } - return [correctAnswer]; + return [stripModelValueDebris(correctAnswer)]; } /** Resolve author-supplied value(s) to 1-based display indices. Keying by diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index 2296f89..4e81c67 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -31,6 +31,7 @@ import { Value } from "typebox/value"; import type { TSchema } from "typebox"; import { quizInputBase, quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputBase, explainInputProblems, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts"; import { restoreMathEscapes } from "./math-delims.ts"; +import { healEncodedArguments } from "./argument-heal.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; /** The pi-boundary parse step, mirrored here (live-smoke fix, 2026-09-26): @@ -89,7 +90,13 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined { // dock renders/gates by it (normalizeQuizType at the read seam); old and // probe records carry none. nodeId stays an unknown key Value.Clean // strips, exactly as before. - const cleaned = boundaryParse(quizDisplayInputSchema, restoreMathEscapesDeep(input)); + // The display mirror of the boundary heal (whitehead, 2026-10-09): the + // record keeps RAW arguments, so the recorded `type: "\"predict\""` + // wobble must heal HERE too or a posed quiz renders invisible (the + // tool-parts law). healEncodedArguments is the same pure pass the + // boundary's withArgumentHeal runs. + const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema); + const cleaned = boundaryParse(quizDisplayInputSchema, healedInput); if (!cleaned) return undefined; if (quizInputProblems(cleaned).length > 0) return undefined; return cleaned; diff --git a/src/core/value-debris.ts b/src/core/value-debris.ts new file mode 100644 index 0000000..2b780dd --- /dev/null +++ b/src/core/value-debris.ts @@ -0,0 +1,26 @@ +/** + * MODEL-VALUE DEBRIS STRIP (owner, 2026-10-09 — the whitehead incident): + * glm-5.3 emitted `correctAnswer: [":parmenides"]` — the option value with + * the JSON key-value COLON glued on — across four rejected quiz calls + * (and `type: "\"predict\""` — the literal with QUOTES baked in — across a + * ten-retry death loop; see tools/heal.ts for that half). Membership + * fields (correctAnswer against option values, quiz type against the + * literal pair, practice targets against proposition ids) are copied by + * the model from its own context, and the copy picks up surrounding + * syntax: colons, quotes, backticks, whitespace. + * + * THE SEAM: strip at every MEMBERSHIP comparison — never on free text + * (statements, questions, explanations may legitimately carry any of + * these characters). Pure + browser-safe: shared by the call boundary + * (quizInputProblems), the display parse (tool-parts), and grading + * (question-dock) — the three consumers the tool-parts law keeps in + * lockstep. Idempotent by construction. + */ + +/** Strip the copy-syntax debris a model glues onto a membership value: + * any run of surrounding whitespace, colons, double/single quotes, and + * backticks, in any interleaving (`":value"`, `: value`, `` `value` ``). + * The interior is untouched. */ +export function stripModelValueDebris(value: string): string { + return value.replace(/^[\s:."'`]+/, "").replace(/[\s:."'`]+$/, ""); +} diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 3777373..1f89fe4 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -689,20 +689,20 @@ describe("teacher chat flow (scripted model)", () => { assert.deepEqual(pending?.input, QUIZ_INPUT); }); - it("a structurally invalid quiz (the live double-encoded options string) never reaches the dock and retries clean", async () => { - // The exact live modal-realism crash shape: GLM occasionally serializes - // a nested array as a JSON STRING inside the arguments. The structural - // layer (JSON Schema: options must be array) rejects the call at the - // boundary — it must surface as tool-input-error, never as a pending - // question, and the model's retry must become the pending quiz. + it("structurally invalid quiz input (unhealable garbage) never reaches the dock and retries clean", async () => { + // The two-tier world (owner, 2026-10-09 — the universal argument + // heal): a JSON-ENCODED stand-in for the schema shape (the live + // string-options habit) now heals at EVERY registry — see + // tests/tool-heal.test.ts for that pin — so the structural reject + // this test holds is the OTHER tier: garbage no mechanical rule + // could rescue (not JSON, not an array) rejects honestly at the + // boundary — tool-input-error, never a pending question — and the + // model's clean retry becomes the pending quiz. const STRINGY_QUIZ_INPUT = { question: "Lewis's indexical analysis of 'actual' — which is it?", type: "check", nodeId: "indexicals", - options: JSON.stringify([ - { label: "Like 'I'", value: "indexical" }, - { label: "Like a title", value: "unique" }, - ]), // ← the bug: a string, not an array + options: "definitely not a JSON array — denim all the way down", // ← unhealable garbage correctAnswer: "indexical", explanation: "Indexical: every world is actual to its own inhabitants.", }; diff --git a/tests/practice-heal.test.ts b/tests/practice-heal.test.ts index 4356557..c2a0ef2 100644 --- a/tests/practice-heal.test.ts +++ b/tests/practice-heal.test.ts @@ -16,11 +16,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { - concludeInputObject, - healEncodedArguments, - problemInputObject, -} from "../src/agent/tools/practice.ts"; +import { concludeInputObject, problemInputObject } from "../src/agent/tools/practice.ts"; +import { healEncodedArguments } from "../src/core/argument-heal.ts"; import { validateToolArguments } from "@earendil-works/pi-ai"; /** The incident's exact mode spelling: the string "recall" INCLUDING diff --git a/tests/tool-heal.test.ts b/tests/tool-heal.test.ts new file mode 100644 index 0000000..7428e85 --- /dev/null +++ b/tests/tool-heal.test.ts @@ -0,0 +1,168 @@ +/** + * THE WHITEHEAD INCIDENT PINS (owner, 2026-10-09): glm-5.3 (z-ai via + * OpenRouter) emitted two argument-wobble shapes in one real session — + * quiz `type: "\"predict\""` (the literal with quotes baked in; the + * schema's own description displayed the values quoted+colon) rejected + * TEN times with byte-identical retries while the lesson never started, + * and `correctAnswer: [":parmenides"]` (the option value with the JSON + * key-value colon glued on) rejected four times. + * + * The fixes these pins hold: + * 1. THE STANDARD HEAL EVERYWHERE — healToolRecord wraps every registry + * (bareQuestionTools carried NO prepareArguments at all), and the + * quote-encoded literal heals before TypeBox validation. + * 2. THE DEBRIS STRIP AT MEMBERSHIP SEAMS — stripModelValueDebris in + * coerceCorrectAnswer (boundary check + grading), normalizeQuizType, + * and resolveTargetId. + * 3. THE DISPLAY MIRROR — the record keeps RAW arguments, so + * parseQuizInput heals the same wobble on read (the tool-parts law: + * a healed-at-boundary quiz whose display parse rejects is an + * invisible pending quiz and a learner lockout). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { validateToolArguments } from "@earendil-works/pi-ai"; +import { healEncodedArguments } from "../src/core/argument-heal.ts"; +import { stripModelValueDebris } from "../src/core/value-debris.ts"; +import { + coerceCorrectAnswer, + normalizeQuizType, + quizInputObject, + quizInputProblems, +} from "../src/core/question-core.ts"; +import { parseQuizInput } from "../src/core/tool-parts.ts"; +import { resolveTargetId } from "../src/core/chains.ts"; +import { bareQuestionTools } from "../src/agent/teacher-agent.ts"; + +/** The incident's exact encoding: the value wrapped in literal quotes. */ +function QUOTED(value: string): string { + return `"${value}"`; +} + +// A complete, well-formed teach-quiz input — the incident's call with +// every field valid BUT the wobbled one. +function whiteheadQuiz(type: unknown, correctAnswer: unknown) { + return { + question: "What was Parmenides' claim?", + options: [ + { label: "Being is changeless and complete", value: "parmenides" }, + { label: "Becoming is fundamental", value: "heraclitus" }, + { label: "Split world", value: "split" }, + { label: "Both unreal", value: "both-illusion" }, + ], + explanation: "What truly *is* cannot come to be or pass away — the flux is appearance.", + correctAnswer, + type, + nodeId: "experience-first", + }; +} + +describe("the death-loop shape — quiz type with quotes baked in", () => { + it("heals the quote-encoded literal (the ten-retry loop)", () => { + const healed = healEncodedArguments({ type: QUOTED("predict") }, quizInputObject) as { + type: unknown; + }; + assert.equal(healed.type, "predict"); + }); + + it("END-TO-END: the healed args pass pi-ai validation where the raw threw", () => { + const raw = whiteheadQuiz(QUOTED("predict"), ["parmenides"]); + assert.throws(() => { + validateToolArguments({ name: "quiz", parameters: quizInputObject } as never, { + id: "call_x", + type: "toolCall", + name: "quiz", + arguments: raw, + } as never); + }); + const validated = validateToolArguments( + { name: "quiz", parameters: quizInputObject } as never, + { + id: "call_x", + type: "toolCall", + name: "quiz", + arguments: healEncodedArguments(raw, quizInputObject), + } as never, + ); + assert.equal((validated as { type: unknown }).type, "predict"); + }); + + it("WIRING: bareQuestionTools (the registry with NO prepareArguments) now heals", () => { + const tools = bareQuestionTools(); + const quiz = tools.quiz; + assert.ok(quiz, "the bare quiz must exist"); + assert.ok(quiz.prepareArguments, "the bare quiz must carry the standard heal"); + const healed = quiz.prepareArguments!( + whiteheadQuiz(QUOTED("predict"), ["parmenides"]), + ) as { type: unknown }; + assert.equal(healed.type, "predict"); + }); + + it("WIRING: the 2026-09-26 string-options shape heals through the bare quiz too", () => { + // The universal heal covers the ORIGINAL incident's shape as well: + // options serialized as a JSON string (then healed only on the + // plan-tree quiz path; the bare registries had nothing). + const quiz = bareQuestionTools().quiz; + assert.ok(quiz?.prepareArguments, "the bare quiz must carry the standard heal"); + const input = whiteheadQuiz("predict", ["parmenides"]); + const stringified = { ...input, options: JSON.stringify(input.options) }; + const healed = quiz.prepareArguments!(stringified) as { options: unknown }; + assert.ok(Array.isArray(healed.options), "the JSON string heals to the array"); + assert.equal( + (healed.options as Array<{ value?: string }>)[0]?.value, + "parmenides", + ); + }); +}); + +describe("the colon shape — correctAnswer with copy-syntax debris", () => { + it("stripModelValueDebris: the observed shapes heal, interiors stay", () => { + assert.equal(stripModelValueDebris(":parmenides"), "parmenides"); + assert.equal(stripModelValueDebris(QUOTED("predict")), "predict"); + assert.equal(stripModelValueDebris(`: "primary-external" `), "primary-external"); + assert.equal(stripModelValueDebris("`value`"), "value"); + assert.equal(stripModelValueDebris("a:b"), "a:b"); // interior colon is content + assert.equal(stripModelValueDebris('["x"]'), '["x"]'); // interior quotes are content (JSON-array strings) + }); + + it("coerceCorrectAnswer strips every entry (boundary check + grading seam)", () => { + assert.deepEqual(coerceCorrectAnswer([":parmenides"]), ["parmenides"]); + assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external")), ["primary-external"]); + }); + + it("the incident's exact call passes the semantic boundary (quizInputProblems)", () => { + const input = whiteheadQuiz("predict", [":parmenides"]) as Parameters< + typeof quizInputProblems + >[0]; + assert.deepEqual(quizInputProblems(input), []); + }); + + it("normalizeQuizType heals a persisted wobbled record", () => { + assert.equal(normalizeQuizType(QUOTED("predict")), "predict"); + assert.equal(normalizeQuizType('"check"'), "check"); + assert.equal(normalizeQuizType(undefined), undefined); + assert.equal(normalizeQuizType("bogus"), undefined); + }); + + it("resolveTargetId heals copy-syntax on targets", () => { + const nodes = [{ id: "experience-first", name: "Experience first" }]; + assert.equal(resolveTargetId(":experience-first", nodes), "experience-first"); + assert.equal(resolveTargetId('"Experience first"', nodes), "experience-first"); + assert.equal(resolveTargetId("nope", nodes), null); + }); +}); + +describe("the display mirror — raw records heal on read", () => { + it("parseQuizInput renders the incident's persisted raw record (no invisible quiz)", () => { + const raw = whiteheadQuiz(QUOTED("predict"), [":parmenides"]); + const parsed = parseQuizInput(raw); + assert.ok(parsed, "the wobbled record must parse — else the quiz is invisible"); + // The display schema's type is DELIBERATELY a lenient string (old + // and probe records carry none); the read seam is normalizeQuizType — + // which strips the recorded wobble to the intended role. + assert.equal(normalizeQuizType(parsed?.type), "predict"); + // The graded answer resolves to the option despite the recorded colon. + assert.deepEqual(parsed ? coerceCorrectAnswer(parsed.correctAnswer) : [], ["parmenides"]); + }); +}); diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index f7d7030..7b1d7a4 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -73,9 +73,18 @@ describe("parseQuizInput", () => { assert.ok(parseQuizInput(PROBER_QUIZ)); }); - it("the live crash shape — options as a JSON string — does not parse", () => { + it("boundary parity: the 2026-09-26 string-options shape heals on read (universal-heal mirror, 2026-10-09)", () => { + // The boundary now heals a JSON-encoded options string at EVERY + // registry (healToolRecord, owner 2026-10-09 — the bare-quiz sets + // carried no prepareArguments at all), and the record keeps the RAW + // args — so the display parse heals the same shape, per this file's + // own parity law: anything the boundary accepts, the display parse + // accepts, or the posed quiz renders nowhere while the turn has + // ended = learner lockout. const stringified = { ...PROBER_QUIZ, options: JSON.stringify(PROBER_QUIZ.options) }; - assert.equal(parseQuizInput(stringified), undefined); + const parsed = parseQuizInput(stringified); + assert.ok(parsed, "the healed-at-boundary record must parse on read"); + assert.deepEqual(parsed.options, PROBER_QUIZ.options); }); it("boundary parity under Value.Convert: a NUMERIC correctAnswer parses (live-smoke lockout fix, 2026-09-26)", () => {