Files
learnharness/src/agent/tools/mermaid-plan.ts
T
amosblomqvist 377d4b6548 fix: the handoff freeze + the free-form mermaid pass
Two user-reported problems from the muscle-protein-synthesis session,
both diagnosed from the live data (chat_mtxeml9e_6118e59f).

The freeze: after probe_done, the open session never showed the
teacher's wake turn — only navigating away and back loaded it. Not an
agent-switch problem: the server-side handoff worked perfectly
(write-ahead persist, liveness registry, the sidebar's busy dot saw the
wake the whole time). The gap was client attach: the only mid-session
wake detector the open page has is the jobs poll, and probe-phase
sessions have zero job traffic, so that poll is OFF; the post-turn
adoption poll read turnInFlight=false at ~0ms (the wake fires at
~0.75-1.2s, after the pump's grace), adopted, and stopped. Fix: the
post-turn adoption effect now keeps a short WATCH WINDOW after adopting
(~4s, 8x500ms) — if turnInFlight flips true, it hands off to the
existing wake-adoption poll (busy shimmer, adopt at finish).

The mermaid failures: four failed plan_init calls, all caused by ONE
hidden rule — underscores are forbidden in node ids (mps_translation,
net_balance, per_meal). The id regex rejected '_' BEFORE the slug check,
so the specific, actionable message never fired; the generic 'can't read
the edge — expected a[title] --> b[title]' lied (the syntax was exactly
that), and the teacher spent three attempts hunting punctuation it
suspected (colons, commas, multi-word labels) — visible in its own
reasoning trail. Per the user's principle — enforce only the slugs,
free everything else: underscores are first-class slug chars; the id
CANDIDATE class is broad so every bad id reaches the specific slug
message; chained edges (a --> b --> c) expand to pairs; ==>, -.-> are
edge synonyms; any direction header accepted (we layout ourselves);
id["title"] quote pairs are stripped. The tool description, zod
describe, and the teacher's planning prompt now state the one strict
rule instead of the old subset liturgy.

Verified: tsc, 305/305 (the exact muscle-protein mermaid is now a
passing test), lint, build; real-browser puppeteer smoke on live GLM —
the teacher's wake turn appeared in the open page ~50s after the probe
concluded with ZERO navigation, verdict gate + first teach check +
plan window all live in-page (the single console 404 is the app's
missing favicon, environmental noise).
2026-09-11 23:54:06 +03:00

164 lines
6.5 KiB
TypeScript

/**
* Mermaid → PlanTree authoring format (the author-once protocol, 2026-09-03;
* free-form pass 2026-09-11 — the muscle-protein session post-mortem: four
* failed plan_init calls over ids the parser silently hated).
*
* The teacher writes the session map as a mermaid block — the format models
* are most fluent in — and plan_init canonicalizes it into the structured
* PlanTree. Hand-parsed subset: deterministic, dependency-free, precise
* line-numbered errors the teacher can act on (the same validate-and-retry
* loop as the question schemas). Not the mermaid runtime — the server never
* renders, it only needs the graph.
*
* THE ONE STRICT RULE: node ids are permanent slugs the teacher references
* via quiz nodeId — lowercase words (a-z, 0-9) joined by hyphens OR
* underscores (`chain-rule-intuition`, `mps_translation`). Everything else
* is as free-form as mermaid allows:
* graph|flowchart TD|TB|LR|RL|BT (direction is cosmetic — we layout
* ourselves; case-insensitive)
* %% comment lines
* node — bare id (title falls back to the id)
* id[title] — square-bracket label; any single-line title, a
* surrounding "…" pair is stripped
* id("title") — paren label
* a --> b — requires edge (from = prerequisite); ==>, -.-> are
* synonyms; chains a --> b --> c expand to pairs
* a[title] --> b[title] — inline labels anywhere an endpoint appears
* Bare endpoints register as nodes; forward references are fine; duplicate
* edges dedupe; the first label a node gets wins.
*/
import type { PlanEdge, PlanNode } from "@/src/core";
export interface ParsedPlan {
nodes: PlanNode[];
edges: PlanEdge[];
}
export interface PlanParseResult {
ok: boolean;
/** All problems, each prefixed with its 1-based line number. */
problems: string[];
plan?: ParsedPlan;
}
const HEADER_RE = /^(graph|flowchart)\s+(td|tb|lr|rl|bt)\s*$/i;
const SLUG_RE = /^[a-z0-9]+([_-][a-z0-9]+)*$/;
/** Any arrow spelling counts as a requires-edge; chains split on it. */
const ARROW_RE = /-->|==>|-\.->/;
/** One edge endpoint: `id`, `id[title]`, or `id("title")`. */
// The id CANDIDATE class is deliberately broad — anything but whitespace,
// brackets, parens — so EVERY bad id (UPPER, dots, underscores…) reaches
// register()'s SPECIFIC slug message instead of dying at the regex layer
// with a generic parse error. The muscle-protein post-mortem: underscores
// failed HERE, the actionable message never fired, and the teacher spent
// four attempts hunting punctuation the parser didn't care about.
const ENDPOINT_RE = /^([^\s()\[\]]+)\s*(?:\[([^\[\]]*)\]|\(\s*"([^"]*)"\s*\)|\(([^()]*)\))?$/;
interface Endpoint {
id: string;
label?: string;
}
function parseEndpoint(text: string): Endpoint | null {
const m = text.trim().match(ENDPOINT_RE);
if (!m || !m[1]) return null;
let label = (m[2] ?? m[3] ?? m[4] ?? "").trim();
// `id["title"]` is idiomatic mermaid — don't keep the quote pair in the title.
if (
label.length >= 2 &&
((label.startsWith('"') && label.endsWith('"')) || (label.startsWith("'") && label.endsWith("'")))
) {
label = label.slice(1, -1).trim();
}
return { id: m[1], label };
}
/** Parse the mermaid subset. Collects ALL problems (never just the first). */
export function parseMermaidPlan(mermaid: string): PlanParseResult {
const problems: string[] = [];
const lines = mermaid.split(/\r?\n/);
// Header: required, first non-empty non-comment line.
const first = lines.findIndex((l) => l.trim().length > 0 && !l.trim().startsWith("%%"));
if (first === -1) {
return { ok: false, problems: ["The plan is empty — expected a `graph TD` block with nodes and `-->` requires-edges."] };
}
const header = lines[first]?.trim() ?? "";
if (!HEADER_RE.test(header)) {
problems.push(`line ${first + 1}: expected the header \`graph TD\` (or flowchart TD/TB) first, got \`${header}\`.`);
}
const nodes = new Map<string, string>(); // id → title (first label wins)
const edges: PlanEdge[] = [];
const seenEdges = new Set<string>();
const register = ({ id, label }: Endpoint, lineNo: number): void => {
if (!SLUG_RE.test(id)) {
problems.push(
`line ${lineNo}: node id \`${id}\` must be a lowercase slug (a-z, 0-9, joined by hyphens or underscores — e.g. \`chain-rule-intuition\`); ids are permanent and checks reference them.`,
);
return;
}
if (!nodes.has(id)) nodes.set(id, label && label.length > 0 ? label : id);
else if (label && label.length > 0 && nodes.get(id) === id) nodes.set(id, label);
};
for (let i = first + 1; i < lines.length; i++) {
const line = lines[i]?.trim() ?? "";
const no = i + 1;
if (line.length === 0 || line.startsWith("%%")) continue;
// Edges (incl. chains): split on any arrow spelling; every segment is
// an endpoint, consecutive pairs become requires-edges.
const segments = line.split(ARROW_RE);
if (segments.length >= 2) {
const endpoints: Endpoint[] = [];
let unreadable = false;
for (const seg of segments) {
const ep = parseEndpoint(seg);
if (!ep) {
problems.push(
`line ${no}: can't read the edge \`${line}\` — expected \`a[title] --> b[title]\` (chains like \`a --> b --> c\` are fine).`,
);
unreadable = true;
break;
}
endpoints.push(ep);
}
if (!unreadable) {
for (const ep of endpoints) register(ep, no);
for (let k = 0; k + 1 < endpoints.length; k++) {
const from = endpoints[k]!;
const to = endpoints[k + 1]!;
if (from.id === to.id) {
problems.push(`line ${no}: self-loop \`${from.id} --> ${to.id}\` — a node can't require itself.`);
continue;
}
const key = `${from.id}->${to.id}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
edges.push({ from: from.id, to: to.id });
}
}
}
} else {
const node = parseEndpoint(line);
if (!node) {
problems.push(
`line ${no}: can't parse \`${line}\` — ids are the strict part (lowercase slugs: a-z, 0-9, hyphens, underscores); node lines (\`slug[title]\`) and requires-edges (\`a --> b\`) are the accepted forms; subgraphs, styles, and classes are not part of the subset.`,
);
continue;
}
register(node, no);
}
}
const plan: ParsedPlan = {
nodes: [...nodes.entries()].map(([id, title]) => ({ id, title })),
edges,
};
if (problems.length > 0) return { ok: false, problems };
if (plan.nodes.length === 0) return { ok: false, problems: ["No nodes found — the plan needs at least one."] };
return { ok: true, problems: [], plan };
}