tools: the standard argument heal — every registry, both seams
The whitehead incident (2026-10-09): glm-5.3 emitted quiz type with
quotes baked in ('"predict"') — rejected ten times, byte-identical
retries, the lesson never started — and correctAnswer with the JSON
key-value colon glued on ([':parmenides']). Third GLM wobble incident,
and each prior heal was wired only where its incident landed; this one
landed precisely where the practice heal never reached.
Layer 1 — healEncodedArguments moves to core/argument-heal (the display
seam needs it too — the tool-parts law) with withArgumentHeal +
healToolRecord (agent/tools/heal) wired at EVERY registry: prober,
teacher, practice, bareQuestionTools (it had no prepareArguments at
all), demo-builder, and submit-agent (composing after
healStringifiedArrays; the auditor's extraTools wrapped).
Layer 2 — stripModelValueDebris (core/value-debris): leading/trailing
colons, quotes, backticks, whitespace strip at the MEMBERSHIP seams —
coerceCorrectAnswer (boundary check + display + grading in one),
normalizeQuizType (persisted wobbled records keep their role),
resolveTargetId (copy-syntax on targets).
Layer 3 — schema description hygiene: the quiz type and practice
mode/outcome/rung/missClass values now read as bare words — the quoted
JSON-key style invited quote-copying.
Display mirror — parseQuizInput heals the raw record on read, so a
boundary-healed quiz whose record keeps raw args renders instead of
becoming an invisible pending quiz (learner lockout).
Pins: tests/tool-heal.test.ts (the incident shapes end-to-end through
pi-ai validation, the wiring, the strips, the mirror). The stringy-
options pins flip to boundary-parity per tool-parts' own law; the
chat-flow garbage pin keeps its honest-reject intent with an unhealable
shape. 1471 tests, typecheck, lint green.
This commit is contained in:
@@ -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<string, AgentTool<any>> {
|
||||
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 {
|
||||
|
||||
@@ -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<T>(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;
|
||||
|
||||
@@ -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<string, AgentTool<any>> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-7
@@ -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<string, AgentTool<any>> downstream).
|
||||
const quizzing: Record<string, AgentTool<any>> =
|
||||
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<string, AgentTool<any>> =
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<any>): AgentTool<any> {
|
||||
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<string, AgentTool<any>>): Record<string, AgentTool<any>> {
|
||||
return Object.fromEntries(Object.entries(tools).map(([name, tool]) => [name, withArgumentHeal(tool)]));
|
||||
}
|
||||
+11
-90
@@ -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<string, PropSchema> },
|
||||
): unknown {
|
||||
if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
|
||||
const properties = schema.properties;
|
||||
if (!properties) return args;
|
||||
let healed: Record<string, unknown> | null = null;
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
const raw = (args as Record<string, unknown>)[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<string, unknown>) };
|
||||
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<string, AgentToo
|
||||
// toggle-filtered: problem/conclude are the session; these are
|
||||
// exceptions the doctrine says to reach for rarely.
|
||||
const jobs = ctx.jobs ? jobTools(ctx.jobs) : null;
|
||||
return {
|
||||
// healToolRecord (owner, 2026-10-09 — the whitehead generalization):
|
||||
// the standard argument heal over the WHOLE registry. problem/conclude
|
||||
// keep their own (target-normalizing) prepareArguments as the composed
|
||||
// inner pass; the double heal is idempotent by construction.
|
||||
return healToolRecord({
|
||||
problem: problemTool(gate, ctx.graphNodes),
|
||||
conclude: concludeTool(gate),
|
||||
read: readTool,
|
||||
@@ -322,5 +243,5 @@ export function practiceTools(ctx: PracticeToolContext): Record<string, AgentToo
|
||||
...(jobs?.research ? { research: jobs.research } : {}),
|
||||
...(jobs ? { pdf: jobs.pdf! } : {}),
|
||||
...(ctx.graph ? proberGraphTools(ctx.graph) : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* THE WEAK-MODEL ARGUMENT HEAL — the pure core (owner, 2026-10-09 —
|
||||
* generalize the 2026-10-04 practice heal; see agent/tools/heal.ts for
|
||||
* the registry-wide wiring story). Lives in core because BOTH seams need
|
||||
* it (the tool-parts law: display parses mirror boundary coercion or a
|
||||
* healed-at-boundary call whose record keeps the RAW arguments renders
|
||||
* as an invisible pending quiz):
|
||||
*
|
||||
* - the agent boundary: pi's prepareArguments seam, BEFORE TypeBox
|
||||
* validation (withArgumentHeal / healToolRecord);
|
||||
* - the display parse: tool-parts' parseQuizInput, before
|
||||
* boundaryParse — the persisted record keeps raw arguments, so the
|
||||
* same wobble heals on read.
|
||||
*
|
||||
* Born of the GLM incident (owner, 2026-10-04): 19 consecutive rejected
|
||||
* `problem` calls, every one carrying `"mode": "\"recall\""` — a
|
||||
* JSON-string-encoded enum — while TypeBox's error text `must be equal
|
||||
* to constant` never named the expected values, so the model re-emitted
|
||||
* the identical payload each time; the 2026-10-09 whitehead session ran
|
||||
* the same loop on quiz `type` for ten turns. 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.
|
||||
*/
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function healEncodedArguments(
|
||||
args: unknown,
|
||||
schema: { properties?: Record<string, PropSchema> },
|
||||
): unknown {
|
||||
if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
|
||||
const properties = schema.properties;
|
||||
if (!properties) return args;
|
||||
let healed: Record<string, unknown> | null = null;
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
const raw = (args as Record<string, unknown>)[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<string, unknown>) };
|
||||
healed[key] = fixed;
|
||||
}
|
||||
}
|
||||
return healed ?? args;
|
||||
}
|
||||
+6
-3
@@ -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<Pick<GraphNode, "id" | "name">>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
+24
-12
@@ -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
|
||||
|
||||
@@ -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<QuizToolInput>(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<QuizToolInput>(quizDisplayInputSchema, healedInput);
|
||||
if (!cleaned) return undefined;
|
||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||
return cleaned;
|
||||
|
||||
@@ -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:."'`]+$/, "");
|
||||
}
|
||||
+10
-10
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user