Files
learnharness/src/agent/subagents/submit-agent.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

135 lines
5.5 KiB
TypeScript

/**
* runSubmitAgent — the shared single-submit agent seam (moved verbatim from
* extraction.ts): one TypeBox-validated submit
* call is the whole deliverable (a pi agent purely so the model can retry a
* schema-invalid call). The submitted value is captured from the execute
* seam, which only fires on a schema-valid call — the ??-fill (the old zod
* defaults) applies at the capture.
*/
import type { Usage } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { TSchema } from "typebox";
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
// is wire-format medicine shared by every submit agent) ──
//
// OpenRouter log evidence: weak models under a big submit payload often
// emit a whole array as ONE JSON-STRING instead of a native array
// ("propositions.0: must be object"). pi-agent-core runs
// `prepareArguments` BEFORE TypeBox validation — the sanctioned shim
// point (same doctrine as restoreMathEscapes at the display seam: heal
// weak-model wire behavior, don't punish it with a retry). Best-effort:
// heal only a string that parses to an array; anything else passes
// through untouched and fails validation exactly as before.
export function healStringifiedArrays(raw: unknown): unknown {
if (typeof raw !== "object" || raw === null) return raw;
const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
for (const key of ["propositions", "chains"]) {
const v = out[key];
if (typeof v !== "string" || v.trim().length === 0) continue;
try {
const parsed: unknown = JSON.parse(v);
if (Array.isArray(parsed)) out[key] = parsed;
} catch {
// not a clean stringify (e.g. truncated) — leave for validation
}
}
return out;
}
export async function runSubmitAgent<T>(opts: {
role: ModelRole;
toolName: string;
toolDescription: string;
parameters: TSchema;
/** Weak-model tolerance: the old zod `.default()`s, applied to the
* schema-valid raw input at the submit seam. */
fill: (input: any) => T;
systemPrompt: string;
prompt: string;
engine: PiEngine | undefined;
signal: AbortSignal | undefined;
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void;
neverSubmitted: string;
/** Extra tools beside the submit call (the auditor's graph-exploration
* set, 2026-09-27): a small agentic loop instead of a bare retry. */
extraTools?: Record<string, AgentTool<any>>;
/** Turn budget for the loop (default 4 = retry-only; the auditor runs
* an exploration loop instead — 16). */
maxTurns?: number;
/** Debug: raw provider-event recording key (LH_PROVIDER_EVENTS=1). */
chatId?: string;
/** Weak-model argument heal at pi's prepareArguments seam (runs BEFORE
* TypeBox validation — the sanctioned shim point). Identity by
* default; extraction wires healStringifiedArrays here. */
prepareArguments?: (args: unknown) => unknown;
/** Semantic validation at the submit seam: the value is schema-valid
* but may violate a PIPELINE law (coverage, echo-equality). Returning
* a problem string yields an isError result (invalidInputResult) →
* the model retries within maxTurns. null = accept. */
validate?: (value: T) => string | null;
}): Promise<T> {
let submitted: T | undefined;
const result = await runSubagent(
[
// 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) =>
// 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;
if (problem) return invalidInputResult(opts.toolName, [problem]);
submitted = value;
return toToolResult(submitted);
},
} satisfies AgentTool<any>,
],
{
role: opts.role,
engine: opts.engine,
systemPrompt: opts.systemPrompt,
prompt: opts.prompt,
maxTurns: opts.maxTurns ?? 4,
abortSignal: opts.signal,
chatId: opts.chatId,
onAssistantMessage: (message, step) =>
opts.onStep(
message.usage,
// Provider-qualified stamp (model slots wave, 2026-09-20):
// pi reports the bare id; usage stamps carry provider:id to
// match the price table's uniform keys.
`${message.provider}:${message.model}`,
step,
message.stopReason,
message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)",
),
shouldStop: (_message, toolResults) => toolResults.some((r) => r.toolName === opts.toolName && !r.isError),
},
);
void result;
if (!submitted) throw new Error(opts.neverSubmitted);
return submitted;
}