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.
97 lines
3.7 KiB
TypeScript
97 lines
3.7 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|