Files
learnharness/src/core/argument-heal.ts
T
amosblomqvist c23eb1f15b 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.
2026-10-09 04:54:34 +03:00

97 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}