practice hardening: weak-model argument heal + parked dismissals mid-turn

The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded
enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never
named the expected values, so the model re-emitted the identical payload
until the dev server was killed. And mid-turn the learner was trapped:
Esc's question/problem rungs only dismissed — the dismiss route 409'd
against the turn-owned record and nothing could stop the running turn.

- healEncodedArguments (practice.ts, the prepareArguments seam): a STRING
  where the schema expects a literal union / boolean / null / number /
  array is JSON.parse-d and kept when it satisfies the schema. Valid args
  pass through untouched (same reference); unhealable values stay for
  validation to reject honestly. Wired on BOTH problem and conclude.
- dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202);
  the turn's settle path (onFinish, after persistTurn, before endTurn —
  the same ordering law as the record write itself) applies it against
  the persisted record, only if the dismissed thing really is pending.
- confirmAbort: question/problem discard while busy now ALSO aborts the
  in-flight turn; the dialog line says so (busy prop).

No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai
validateToolArguments end-to-end; park registry, apply paths, the 202
route contract).
This commit is contained in:
amosblomqvist
2026-10-04 23:57:00 +03:00
parent 4640117ba7
commit 85022ae143
9 changed files with 544 additions and 17 deletions
+87
View File
@@ -20,6 +20,91 @@ import { activeProblemOf } from "../../core/native-messages.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { concludeResult, invalidInputResult, problemResult } from "../tool-execute.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 {
chatId: string;
@@ -70,6 +155,7 @@ export function problemTool(ctx: PracticeToolContext): AgentTool<typeof problemI
"Only one problem may be active — conclude it before posing the next.",
parameters: problemInputObject,
executionMode: "sequential",
prepareArguments: (args: unknown) => healEncodedArguments(args, problemInputObject) as never,
execute: async (_toolCallId, _input) => {
const record = ctx.chatStore.getChat(ctx.chatId);
if (record && activeProblemOf(record.messages) !== null) {
@@ -130,6 +216,7 @@ export function concludeTool(ctx: PracticeToolContext): AgentTool<typeof conclud
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
"up in text). Nothing may conclude unless a problem is active.",
parameters: concludeInputObject,
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
executionMode: "sequential",
execute: async (_toolCallId, _input) => {
const record = ctx.chatStore.getChat(ctx.chatId);
+8
View File
@@ -35,6 +35,7 @@ import {
} from "../core/settings-resolution.ts";
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { applyParkedDismissal } from "./dismissal-park.ts";
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
@@ -426,6 +427,13 @@ export async function handleChatInput(
// against it and the pipeline's batches share it.
triggerExtraction(stores, extraction, chatId, effective.graph.extractionChunkTokens);
}
// PARKED DISMISSAL (owner, 2026-10-04): a dismissal that arrived
// mid-turn was parked by the dismiss route (202); now that
// persistTurn has landed the turn's messages, apply it against
// real truth — BEFORE endTurn, so "turn-settled" guarantees a
// subsequent fetch reads the dismissal already in the record
// (the same ordering law as persistTurn itself).
applyParkedDismissal(stores.chatStore, chatId);
// LAST: a poller that sees not-in-flight must read an already-persisted
// record — endTurn only after persistTurn has done its job.
endTurn(chatId);
+80
View File
@@ -0,0 +1,80 @@
/**
* PARKED DISMISSALS (owner, 2026-10-04): ESC-discard while the turn is
* still working. Mid-turn the STORED record holds no pending question or
* problem (the turn hasn't persisted — its messages land at settle), so a
* dismissal arriving now CANNOT validate; the old behavior was a hard 409,
* which left the learner trapped with a phantom question until the turn
* died (the GLM practice-loop incident: the only escape was killing the
* dev server).
*
* The park (owner ruling: "server-side park"): the dismiss route, finding
* a live turn, parks the intent and answers 202; the turn's settle path
* (chat-handler onFinish, AFTER persistTurn, BEFORE endTurn — the same
* ordering law that makes turn-settled imply durable truth) applies it
* against the freshly persisted record. Validation runs at APPLY time
* against real state: if the dismissed question/problem really is pending
* there, the dismissal lands exactly as the route would have written it;
* if it never materialized (the call errored, the model re-posed and
* answered), the park is dropped silently — the thing the user dismissed
* no longer exists.
*
* Lives on globalThis, NOT a module-level Map — the same Next.js module
* instance discipline as turn-liveness: the dismiss route and the
* chat-handler entry must see the SAME registry or the park is written in
* one world and read in another. Ephemeral by design; one parked
* dismissal per chat (last write wins — there is one dock, one discard).
*/
import { ChatInputError, dismissAppends, emit } from "../core";
import type { ChatStore } from "../core/ports.ts";
/** The parked dismissal body — exactly the parsed input the dismiss route
* accepted ({type:"dismiss", toolCallId, answer} or
* {type:"dismiss-problem", problemCallId}). */
export type ParkedDismissal = unknown;
interface LhGlobal {
__lhParkedDismissals?: Map<string, ParkedDismissal>;
}
function registry(): Map<string, ParkedDismissal> {
const g = globalThis as typeof globalThis & LhGlobal;
return (g.__lhParkedDismissals ??= new Map<string, ParkedDismissal>());
}
/** Park (or overwrite) the chat's dismissal intent. Called by the dismiss
* route when a turn owns the chat. */
export function parkDismissal(chatId: string, input: ParkedDismissal): void {
registry().set(chatId, input);
}
/** Take the chat's parked dismissal (removes it). Returns null when
* nothing is parked. */
export function takeParkedDismissal(chatId: string): ParkedDismissal | null {
const parked = registry().get(chatId);
if (parked === undefined) return null;
registry().delete(chatId);
return parked;
}
/** Apply the chat's parked dismissal, if any, against the current record.
* Runs in the turn's onFinish — between persistTurn and endTurn, so a
* client reacting to "turn-settled" reads the dismissal already in the
* record. The append path is the route's own (dismissAppends); a
* ChatInputError means the dismissed thing never materialized — the park
* is dropped, silence is the correct outcome. Emits "record-changed"
* exactly when something was written. */
export function applyParkedDismissal(chatStore: ChatStore, chatId: string): void {
const input = takeParkedDismissal(chatId);
if (input === null) return;
const record = chatStore.getChat(chatId);
if (!record) return;
try {
const appends = dismissAppends(record, input as Parameters<typeof dismissAppends>[1]);
chatStore.saveMessages(chatId, [...record.messages, ...appends]);
emit("record-changed", { chatId });
} catch (err) {
if (err instanceof ChatInputError) return; // never materialized — drop
throw err;
}
}
+1
View File
@@ -37,6 +37,7 @@ export { authStore } from "../store/auth.ts";
export * from "./settings-body.ts";
export * from "./turn-liveness.ts";
export * from "./turn-runs.ts";
export * from "./dismissal-park.ts";
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";