feat: probe/teach agent split — explicit phase markers, position-only plan map
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on 'plan' forever. Two causes: (1) the three-phase view read the stored tree's currentNodeId, which nothing ever writes post author-once (2026-09-03) — a structural dead seam; (2) the teacher stochastically left quiz.nodeId untagged, and done-by-quiz-result semantics would have falsely completed nodes anyway. The redesign (docs/probe-teach-split.md) abolishes derived occupancy in favor of explicit events: - Two agents over one transcript: createProberAgent (quiz without a nodeId field, ask, probe_done) and createTeacherAgent (full toolset, plan_init onward). The chat-handler construction branch keys on a probe_done part in the transcript. MODELS.prober added. - probe_done: server-side seam tool — carries the learner assessment (strands + free-form edge read), refuses while a question is open or the probe is already concluded, and queues a handoff steer through the generalized wake pipeline (steers now dedupe by id, not jobId; job finalization only for job steers). The teacher auto-wakes and authors the plan; the learner sees the assessment via the probe-done card. - phases: probe ends at a concluded probe_done, plan ends at a successful plan_init — strictly sequential, no skipping, no legacy compatibility (data wiped at the pivot per doctrine). - Plan map = position, not completion: derivePlanStatuses reads only tagged quiz CALLS (state/outcome feed nothing); current = latest tagged node with consecutive duplicates collapsed, visited = earlier distinct stays (a revisit stays in both — current wins at render). PlanTree is structure-only; currentNodeId/doneNodeIds and the dead mutation helpers are gone. No fallback current. - Prompts split per agent (prompts/prober/, prompts/teacher/ over the trimmed shared base); graph-context injection removed entirely (shelved, docs/probe-teach-split.md §5); knowledge-graph context no longer reaches either agent. - Plan blocks are full-only snapshots at plan_init (neighborhood machinery retired; the persistent DAG window is the future UI wave); usage capture unchanged. Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question- tools rewritten to the new semantics; probe-done + handoff-wake tests added), lint, build. Live GLM smoke PASS: prober concluded with an honest assessment, teacher auto-woke, authored a 10-node plan with the block attached, then tagged checks on slope-instinct → secant derived current/ visited exactly as designed; grading honest both ways (driver off-by-one proved a wrong answer grades false).
This commit is contained in:
@@ -13,3 +13,4 @@ export * from "./contracts/plan.ts";
|
||||
export * from "./contracts/graph.ts";
|
||||
export * from "./contracts/chat.ts";
|
||||
export * from "./contracts/jobs.ts";
|
||||
export * from "./contracts/probe.ts";
|
||||
|
||||
@@ -124,12 +124,15 @@ export interface TurnUsage {
|
||||
* small container, the current node glowing — clearly a fragment, not the
|
||||
* whole plan. Clicking either opens the full plan in a popup. */
|
||||
export interface PlanBlockData {
|
||||
kind: "full" | "neighborhood";
|
||||
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
|
||||
* FLOW once, at authoring. The neighborhood fragments are retired —
|
||||
* position derives live from the transcript (the persistent DAG
|
||||
* window is the successor). */
|
||||
kind: "full";
|
||||
at: string;
|
||||
/** The current node at snapshot time (the glow target; null = none). */
|
||||
current: string | null;
|
||||
/** Included nodes: all of them (full) or the one-level-up/one-level-down
|
||||
* neighborhood of `current` (neighborhood). Statuses are the snapshot's,
|
||||
/** All nodes of the authored tree. Statuses are the snapshot's,
|
||||
* derived at emission time — the block never re-derives at render. */
|
||||
nodes: Array<{ id: string; title: string; status: PlanNodeStatus }>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
@@ -141,9 +144,9 @@ export interface PlanBlockData {
|
||||
* them in listChats. Liveness (`busy`) is NOT record-derived — the API
|
||||
* route merges it in (see ChatListItem).
|
||||
*
|
||||
* Phase semantics (three phases, data-only): probe = no plan yet, plan =
|
||||
* tree exists but no node occupied, teach = a node is occupied
|
||||
* (core/phases.ts phaseOfTree).
|
||||
* Phase semantics (three phases, event-driven — docs/probe-teach-split.md
|
||||
* §4): probe ends at the concluded probe_done, plan ends at the successful
|
||||
* plan_init; derived from the transcript (core/phases.ts phaseAt3).
|
||||
*/
|
||||
export interface ChatSummary {
|
||||
id: string;
|
||||
|
||||
@@ -242,7 +242,7 @@ export interface AbortSteerMessage extends UIMessage {
|
||||
}
|
||||
|
||||
/** Anything the wake pipeline may queue and deliver. */
|
||||
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage;
|
||||
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage | ProbeHandoffSteerMessage;
|
||||
|
||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
|
||||
@@ -326,10 +326,39 @@ function abortText(kind: JobKind): string {
|
||||
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
|
||||
}
|
||||
|
||||
/** True when the transcript already carries this job's steer (idempotent delivery). */
|
||||
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
|
||||
return messages.some(
|
||||
(m) => (m.metadata as { subagentResult?: { jobId?: string } } | undefined)?.subagentResult?.jobId === jobId,
|
||||
);
|
||||
// ─── the probe handoff steer (the probe→teach split, docs/probe-teach-split.md §2) ──
|
||||
//
|
||||
// probe_done's execute queues this through the same wake pipeline; the
|
||||
// pump fires it once the prober's turn ends (gates + grace). It carries no
|
||||
// artifact — the assessment it references is durable in the transcript as
|
||||
// the probe_done tool part. Deterministic id per chat: exactly one
|
||||
// handoff, idempotent re-delivery.
|
||||
|
||||
export interface ProbeHandoffSteerMessage extends UIMessage {
|
||||
id: `steer_probe_${string}`;
|
||||
role: "user";
|
||||
metadata: { probeHandoff: { chatId: string } };
|
||||
parts: [{ type: "text"; text: string }];
|
||||
}
|
||||
|
||||
export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage {
|
||||
return {
|
||||
id: `steer_probe_${chatId}`,
|
||||
role: "user",
|
||||
metadata: { probeHandoff: { chatId } },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The probe is concluded — the learner assessment is recorded. Author the session's plan map now (call plan_init) and present it, then begin teaching.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the transcript already carries this steer — idempotent
|
||||
* delivery for ANY steer kind: every steer id is deterministic per its
|
||||
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
|
||||
export function steerDelivered(messages: UIMessage[], steerId: string): boolean {
|
||||
return messages.some((m) => m.id === steerId);
|
||||
}
|
||||
|
||||
|
||||
+36
-51
@@ -29,39 +29,35 @@ export interface PlanEdge {
|
||||
export interface PlanTree {
|
||||
nodes: PlanNode[];
|
||||
edges: PlanEdge[];
|
||||
currentNodeId: string | null;
|
||||
doneNodeIds: string[];
|
||||
/** Increments on every mutation — lets the UI animate/react to changes. */
|
||||
version: number;
|
||||
}
|
||||
|
||||
// NOTE (2026-09-11 pivot, docs/probe-teach-split.md): the tree once carried
|
||||
// `currentNodeId` / `doneNodeIds` — progress fields. They are GONE by
|
||||
// design: the tree carries structure ONLY; position and visited-ness derive
|
||||
// from the transcript at render time (derivePlanStatuses below). There is no
|
||||
// done ledger of any kind. No backwards compatibility for old records.
|
||||
|
||||
/** Derived, never stored: status of a node for the panel (vision §2). */
|
||||
export type PlanNodeStatus = "done" | "current" | "upcoming";
|
||||
export type PlanNodeStatus = "current" | "visited" | "upcoming";
|
||||
|
||||
export function planNodeStatus(tree: PlanTree, nodeId: string): PlanNodeStatus {
|
||||
if (tree.currentNodeId === nodeId) return "current";
|
||||
if (tree.doneNodeIds.includes(nodeId)) return "done";
|
||||
return "upcoming";
|
||||
}
|
||||
|
||||
// ─── derived statuses: the author-once protocol (2026-09-03) ─────────────────────
|
||||
// ─── derived statuses: position from CALLS, never from results ─────────────
|
||||
//
|
||||
// The teacher AUTHORS the map (plan_init, mermaid — once at the seam,
|
||||
// re-callable to rewrite) and never tracks progress: statuses derive from
|
||||
// the transcript, pure and at render time. A check names its node (quiz
|
||||
// input nodeId — validated at the call boundary against the live tree);
|
||||
// the derivation reads outcomes:
|
||||
// done — any check on the node that the learner PASSED (demonstration-
|
||||
// only: "explaining is yours, done is theirs")
|
||||
// current — the node of the last UNANSWERED check; if none, the first
|
||||
// node in authoring order whose prerequisites are all done and
|
||||
// that isn't done itself (forward-looking: the panel leads the
|
||||
// lesson — during teaching it points at what's coming, and holds
|
||||
// through the check that follows)
|
||||
// upcoming — everything else
|
||||
// Untagged checks and nodeIds missing from the current tree are lost signal
|
||||
// (the standing doctrine); a rewritten map keeps honest progress for reused
|
||||
// slugs because the derivation matches ids against the CURRENT tree.
|
||||
// (2026-09-11 pivot — docs/probe-teach-split.md §3.) The teacher AUTHORS the
|
||||
// map (plan_init, mermaid — re-callable rewrite) and never tracks progress.
|
||||
// The quiz `nodeId` means "this question is ABOUT this node" — a position
|
||||
// signal, never a completion signal. Derivation reads CALLS only:
|
||||
// current — the node of the most RECENT tagged quiz call (any state)
|
||||
// visited — every node referenced by an EARLIER tagged call
|
||||
// Consequences: a node spans multiple turns/checks naturally (several
|
||||
// questions may tag the same node — the map holds still); moving to a new
|
||||
// node is the teacher's judgment the previous one is finished, regardless of
|
||||
// check outcomes (a failed check then a move-on is still visited); results
|
||||
// feed nothing; there is NO fallback current (before the first tagged check
|
||||
// nothing is current). Untagged checks are legal ad-hoc checks — they move
|
||||
// nothing. NodeIds missing from the current tree are ignored (a rewrite
|
||||
// may have dropped the node).
|
||||
|
||||
/** Minimal structural message slice — anything with typed parts (mirrors
|
||||
* phases.ts: pure, no runtime deps, safe on server, client, and in tests). */
|
||||
@@ -70,48 +66,37 @@ interface PartsCarrier {
|
||||
}
|
||||
|
||||
export interface DerivedPlanStatuses {
|
||||
/** The node the most recent tagged quiz call is about; null before any. */
|
||||
current: string | null;
|
||||
/** Node ids demonstrated by the learner (passed checks). */
|
||||
done: ReadonlySet<string>;
|
||||
/** Nodes referenced by earlier tagged calls — been there, monotonic. */
|
||||
visited: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Statuses of a node: the derived view the plan panel should render. */
|
||||
export function derivedNodeStatus(statuses: DerivedPlanStatuses, nodeId: string): PlanNodeStatus {
|
||||
if (statuses.current === nodeId) return "current";
|
||||
if (statuses.done.has(nodeId)) return "done";
|
||||
if (statuses.visited.has(nodeId)) return "visited";
|
||||
return "upcoming";
|
||||
}
|
||||
|
||||
export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarrier[]): DerivedPlanStatuses {
|
||||
const ids = new Set(tree.nodes.map((n) => n.id));
|
||||
const done = new Set<string>();
|
||||
let current: string | null = null;
|
||||
|
||||
const runs: string[] = []; // distinct tagged nodes in call order, CONSECUTIVE
|
||||
// duplicates collapsed (several checks on one node = one stay on it)
|
||||
for (const m of messages) {
|
||||
for (const part of m.parts) {
|
||||
if (part.type !== "tool-quiz") continue;
|
||||
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
|
||||
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown = lost signal
|
||||
if (part.state === "input-available") {
|
||||
current = nodeId; // a check is out — that's where we are
|
||||
} else if (part.state === "output-available") {
|
||||
if ((part.output as { correct?: unknown } | undefined)?.correct === true) done.add(nodeId);
|
||||
// failed checks change nothing: resume teaching, re-check later
|
||||
}
|
||||
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing
|
||||
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (current === null) {
|
||||
// No check out: the first node (authoring order) whose prerequisites
|
||||
// are all demonstrated and that isn't done itself — the next lesson.
|
||||
const prereqs = new Map<string, string[]>();
|
||||
for (const e of tree.edges) prereqs.set(e.to, [...(prereqs.get(e.to) ?? []), e.from]);
|
||||
current =
|
||||
tree.nodes.find(
|
||||
(n) => !done.has(n.id) && (prereqs.get(n.id) ?? []).every((p) => done.has(p)),
|
||||
)?.id ?? null;
|
||||
}
|
||||
return { current, done };
|
||||
const current = runs.at(-1) ?? null;
|
||||
// Visited = been there in an EARLIER stay: everything before the current
|
||||
// run. A revisit (a→b→a) puts a in visited AND current — current wins at
|
||||
// render (derivedNodeStatus), visited records it has been left before.
|
||||
const visited = new Set(runs.slice(0, -1));
|
||||
return { current, visited };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Probe-phase contracts (the probe→teach split, docs/probe-teach-split.md).
|
||||
*
|
||||
* The prober concludes its phase with `probe_done` — a server-side tool
|
||||
* whose INPUT is the learner assessment (authored by the model, streamed to
|
||||
* the learner live, durable in the transcript by the tool result itself).
|
||||
* The first `probe_done` in the transcript is the probe→planning seam: the
|
||||
* teacher agent is constructed from that point on, and the tool's execute
|
||||
* queues the handoff steer through the wake pipeline so the teacher starts
|
||||
* immediately after the prober's turn ends (auto-wake, user-decided).
|
||||
*
|
||||
* Assessment shape (v1 — refine later): the probe was a binary search per
|
||||
* dependency strand; the assessment formalizes exactly that. A list of the
|
||||
* strands the lesson will depend on, each with a free-form read of where
|
||||
* the learner's edge sits.
|
||||
*/
|
||||
|
||||
/** One dependency strand the planned lesson will rest on. */
|
||||
export interface ProbeStrand {
|
||||
/** Name/description of the strand (e.g. "the derivative as a limit"). */
|
||||
strand: string;
|
||||
/** Free-form: where the learner's edge sits on this strand — floor,
|
||||
* ceiling, misconceptions found, what to build from. */
|
||||
assessment: string;
|
||||
}
|
||||
|
||||
/** The prober's read of the learner, carried by probe_done. */
|
||||
export interface ProbeAssessment {
|
||||
strands: ProbeStrand[];
|
||||
/** Optional overall synthesis: the learner's apparent goal and edge. */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/** probe_done tool input (the assessment the prober authors). */
|
||||
export interface ProbeDoneInput {
|
||||
assessment: ProbeAssessment;
|
||||
}
|
||||
|
||||
/** probe_done tool output. */
|
||||
export interface ProbeDoneResult {
|
||||
ok: boolean;
|
||||
/** Refusal reason when ok === false (open question, already concluded). */
|
||||
error?: string;
|
||||
assessment?: ProbeAssessment;
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Graph-as-context (W8; vision §1 "The graph as session context", DECIDED
|
||||
* 2026-08-22).
|
||||
*
|
||||
* renderGraphContext() turns the derived graph into a compact markdown block
|
||||
* the teacher sees as part of its instructions — this is what makes probing
|
||||
* precise instead of generic: probe questions can target the boundary of
|
||||
* what the learner actually holds, and personalization compounds across
|
||||
* sessions.
|
||||
*
|
||||
* buildGraphContext() is the per-request entry point the chat-handler calls
|
||||
* (stateless: it re-reads the ledgers, so it self-updates as the graph grows
|
||||
* mid-session). An empty graph returns null — nothing is injected, and the
|
||||
* prompt fragment (50-graph-context.md) tells the teacher how to behave when
|
||||
* the block is absent (new learner).
|
||||
*
|
||||
* Selection strategy (§1 "the selection problem", simplest first): the WHOLE
|
||||
* graph while it is small; past MAX_NODES, hotspots (missed / dont-know
|
||||
* nodes) plus their 1-hop `follows` neighborhood — the frontier is where
|
||||
* the next session should probe.
|
||||
*/
|
||||
|
||||
import type { GraphEdge, GraphNode, LedgerEventKind } from "./contracts.ts";
|
||||
import { latestQuizOutcomes, type DerivedGraph } from "./graph.ts";
|
||||
|
||||
/** The section header the teacher is taught to recognize (50-graph-context.md). */
|
||||
export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
|
||||
|
||||
/** Above this node count only hotspots + their 1-hop neighborhood are shown. */
|
||||
export const MAX_CONTEXT_NODES = 150;
|
||||
|
||||
/**
|
||||
* Group order for node lines: diagnostic value first (vision §1 hotspots).
|
||||
* No node-state classes (removed 2026-09-08) — the grouping is the raw
|
||||
* event fact: the node's LATEST quiz outcome (miss/dont-know = hotspot).
|
||||
*/
|
||||
type Outcome = LedgerEventKind | "never-probed";
|
||||
|
||||
function groupOf(outcome: Outcome): number {
|
||||
if (outcome === "miss" || outcome === "dont-know") return 0;
|
||||
if (outcome === "correct") return 1;
|
||||
return 2; // never probed
|
||||
}
|
||||
|
||||
const GROUP_LABELS = [
|
||||
"Hotspots (last answer wrong or don't-know) — likely misconception sites and unexplored ground:",
|
||||
"Answered correctly:",
|
||||
"Never probed (assume nothing):",
|
||||
] as const;
|
||||
|
||||
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
|
||||
function isHotspot(outcome: Outcome): boolean {
|
||||
return groupOf(outcome) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the context block, or null when there is nothing worth injecting
|
||||
* (no nodes; or a truncated graph whose selection came out empty).
|
||||
*
|
||||
* Pure: callers hand in exactly what getGraph(graphStore) produces; the
|
||||
* outcomes are the raw latest-quiz-event facts (core/graph.ts).
|
||||
*/
|
||||
export function renderGraphContext(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
outcomes: Map<string, LedgerEventKind>,
|
||||
): string | null {
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
// ── selection ───────────────────────────────────────────────────────────
|
||||
const outcomeOf = (node: GraphNode): Outcome => outcomes.get(node.id) ?? "never-probed";
|
||||
let selected = nodes;
|
||||
let truncated = false;
|
||||
if (nodes.length > MAX_CONTEXT_NODES) {
|
||||
truncated = true;
|
||||
const hotspots = nodes.filter((n) => isHotspot(outcomeOf(n)));
|
||||
const keep = new Set(hotspots.map((n) => n.id));
|
||||
// 1-hop follows neighborhood: premises AND derivations.
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== "follows") continue;
|
||||
if (keep.has(edge.from) || keep.has(edge.to)) {
|
||||
keep.add(edge.from);
|
||||
keep.add(edge.to);
|
||||
}
|
||||
}
|
||||
selected = nodes.filter((n) => keep.has(n.id));
|
||||
if (selected.length === 0) return null; // nothing diagnostic to show
|
||||
}
|
||||
|
||||
// ── node lines, grouped/sorted by diagnostic priority ──────────────────────────
|
||||
const lines: string[] = [
|
||||
`===== ${GRAPH_CONTEXT_HEADER} =====`,
|
||||
`${truncated ? `showing ${selected.length} of ${nodes.length} nodes (hotspots + their 1-hop neighborhood)` : `${nodes.length} nodes`}, ${edges.filter((e) => e.type === "follows").length} follows edges`,
|
||||
];
|
||||
|
||||
for (const [group, label] of GROUP_LABELS.entries()) {
|
||||
const groupNodes = selected
|
||||
.filter((n) => groupOf(outcomeOf(n)) === group)
|
||||
.sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0));
|
||||
if (groupNodes.length === 0) continue;
|
||||
lines.push("", label);
|
||||
for (const node of groupNodes) {
|
||||
const outcome = outcomeOf(node);
|
||||
const tag = outcome === "never-probed" ? "never probed" : outcome;
|
||||
lines.push(`• [${tag}] ${node.statement} (${node.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── requires edges, merged into chains ──────────────────────────────────
|
||||
const byId = new Map(selected.map((n) => [n.id, n.statement]));
|
||||
const follows = edges.filter(
|
||||
(e) => e.type === "follows" && byId.has(e.from) && byId.has(e.to),
|
||||
);
|
||||
if (follows.length > 0) {
|
||||
lines.push("", "Follows (premise → derivation):");
|
||||
for (const chain of edgeChains(follows)) {
|
||||
lines.push(chain.map((id) => byId.get(id) ?? id).join(" → "));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("===== end of knowledge graph =====");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a set of requires edges into maximal simple chains: a→b, b→c, a→d
|
||||
* renders as "a → b → c" and "a → d". Each edge appears in exactly one chain.
|
||||
*/
|
||||
function edgeChains(edges: GraphEdge[]): string[][] {
|
||||
const chains: string[][] = [];
|
||||
const used = new Set<GraphEdge>();
|
||||
|
||||
const sorted = [...edges].sort((a, b) =>
|
||||
`${a.from}\u0000${a.to}` < `${b.from}\u0000${b.to}` ? -1 : 1,
|
||||
);
|
||||
for (const edge of sorted) {
|
||||
if (used.has(edge)) continue;
|
||||
used.add(edge);
|
||||
let chain = [edge.from, edge.to];
|
||||
// Extend forward along unused outgoing edges (branching yields sibling
|
||||
// chains; a cycle stops the walk).
|
||||
for (;;) {
|
||||
const tail = chain[chain.length - 1]!;
|
||||
const next = sorted.find((e) => !used.has(e) && e.from === tail);
|
||||
if (!next || chain.includes(next.to)) break; // branching or a cycle — stop
|
||||
used.add(next);
|
||||
chain.push(next.to);
|
||||
}
|
||||
// Extend backward along unused incoming edges, symmetrically.
|
||||
for (;;) {
|
||||
const head = chain[0]!;
|
||||
const prev = sorted.find((e) => !used.has(e) && e.to === head);
|
||||
if (!prev || chain.includes(prev.from)) break;
|
||||
used.add(prev);
|
||||
chain.unshift(prev.from);
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
return chains;
|
||||
}
|
||||
|
||||
// ─── the per-request entry point ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the graph context block for one request. Pure: the caller hands in
|
||||
* the derived graph (production: getGraph(graphStore) per request in the
|
||||
* chat-handler, so it self-updates as the graph grows mid-session).
|
||||
*/
|
||||
export function buildGraphContext(graph: DerivedGraph): string | null {
|
||||
const outcomes = latestQuizOutcomes(graph.nodes, graph.events);
|
||||
return renderGraphContext(graph.nodes, graph.edges, outcomes);
|
||||
}
|
||||
+2
-1
@@ -373,7 +373,8 @@ const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-
|
||||
* colors nodes by quiz results anymore). Used by the teacher's
|
||||
* graph-as-context selection (graph-context.ts) to find diagnostic
|
||||
* hotspots; the home renderer does not consume it. Nodes with no quiz
|
||||
* events have no entry.
|
||||
* events have no entry. (The graph-as-context consumer is shelved —
|
||||
* docs/probe-teach-split.md §5 — but the event fact stays.)
|
||||
*/
|
||||
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
|
||||
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
||||
|
||||
+4
-5
@@ -9,11 +9,10 @@
|
||||
* components/** → "@/src/core" (this barrel, ONLY)
|
||||
* app/** → "@/src/core" + "@/src/server"
|
||||
*
|
||||
* Server-only core modules (core/graph.ts emission/derivation,
|
||||
* core/graph-context.ts) are pure but need storage ports passed in by a
|
||||
* server caller, so they are deliberately NOT re-exported here — the
|
||||
* server imports them directly. The storage PORT TYPES are types-only and
|
||||
* browser-safe, hence re-exported.
|
||||
* Server-only core modules (core/graph.ts emission/derivation) are pure
|
||||
* but need storage ports passed in by a server caller, so they are
|
||||
* deliberately NOT re-exported here — the server imports them directly.
|
||||
* The storage PORT TYPES are types-only and browser-safe, hence re-exported.
|
||||
*/
|
||||
|
||||
export * from "./contracts.ts";
|
||||
|
||||
+49
-117
@@ -1,128 +1,69 @@
|
||||
/**
|
||||
* Probe/teach phase derivation (vision §2 "Two phases, clearly separated").
|
||||
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
|
||||
* occupancy. The probe→teach agent split (2026-09-11) gave every phase
|
||||
* boundary a transcript marker:
|
||||
*
|
||||
* The session's phase is DERIVED, never stored: `plan_init` IS the transition
|
||||
* marker — the call already fires exactly once at the seam (verified live in
|
||||
* Gate 1), so no new agent tool exists. Phase = "probe" until the first
|
||||
* message containing a `plan_init` tool part; from that message onward,
|
||||
* phase = "teach". Everything after it (checks, follow-up asks) is teaching.
|
||||
* probe — until the first message containing a `tool-probe_done` part
|
||||
* (the prober concludes; the assessment rides the tool part)
|
||||
* plan — probe concluded, until the first successful `plan_init`
|
||||
* (the teacher presents the map; interim rule until the
|
||||
* accept-plan UI lands — then acceptance becomes the seam)
|
||||
* teach — a plan exists
|
||||
*
|
||||
* Pure: no imports, no runtime deps — safe on server, client, and in tests.
|
||||
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
|
||||
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a
|
||||
* session could never leave "plan"), and the stored tree no longer carries
|
||||
* progress fields at all. No backwards compatibility: legacy sessions
|
||||
* without probe_done simply read as probe (they are wiped data).
|
||||
*
|
||||
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
||||
*/
|
||||
|
||||
/** The two phases of a session. */
|
||||
export type Phase = "probe" | "teach";
|
||||
|
||||
/** Minimal structural slice of a UI message — anything with typed parts. */
|
||||
export interface PhaseMessage {
|
||||
parts: ReadonlyArray<{ type: string }>;
|
||||
}
|
||||
|
||||
const PLAN_INIT_PART = "tool-plan_init";
|
||||
|
||||
/**
|
||||
* The phase at `upToIndex` (inclusive; default: the last message).
|
||||
*
|
||||
* Scans messages[0..upToIndex] for the FIRST message containing a
|
||||
* `tool-plan_init` part — any part state counts (the call itself is the
|
||||
* transition; teaching has begun even while the call's input streams). The
|
||||
* message that contains plan_init is already teach: the divider renders
|
||||
* exactly there, so probe content sits above it, teaching below.
|
||||
*
|
||||
* Boundary semantics:
|
||||
* - no plan_init anywhere → "probe" at every index (pre-plan sessions keep
|
||||
* the old auto-open behavior for every question — backward compatible).
|
||||
* - first plan_init in message i → phaseAt(m, i-1) === "probe",
|
||||
* phaseAt(m, i) === "teach", and every index after.
|
||||
* - multiple plan_init parts → the FIRST wins (a re-init attempt later in
|
||||
* the transcript never moves the seam; the tool refuses re-init anyway).
|
||||
*/
|
||||
export function phaseAt(messages: readonly PhaseMessage[], upToIndex?: number): Phase {
|
||||
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||
for (let i = 0; i <= last; i++) {
|
||||
const parts = messages[i]?.parts;
|
||||
if (parts?.some((p) => p.type === PLAN_INIT_PART)) return "teach";
|
||||
}
|
||||
return "probe";
|
||||
}
|
||||
|
||||
// ─── The three-phase view (probe / plan / teach) ──────────────────────────────
|
||||
//
|
||||
// The session picker's color strips, the header chip, and the transcript
|
||||
// dividers all speak THREE phases, derived data-only from the plan tree:
|
||||
//
|
||||
// probe — no plan output yet (the tree is empty or absent);
|
||||
// plan — a tree exists but no node is occupied (plan_set_current not fired);
|
||||
// teach — a node is occupied.
|
||||
//
|
||||
// The teacher's invisible PRE-plan planning (reasoning before plan_init
|
||||
// fires) reads as probe until the plan lands — an accepted limitation: no
|
||||
// observable signal marks "I'm now planning" (a teacher-side marker would be
|
||||
// an agent change; see phases.md discussion in the transcript divider work).
|
||||
|
||||
/** The three phases of a session, as the picker / header / dividers see it. */
|
||||
/** The three phases of a session, as the picker / header / dividers see them. */
|
||||
export type Phase3 = "probe" | "plan" | "teach";
|
||||
|
||||
/** The advance-only order of phases: a session never visibly regresses. */
|
||||
const PHASE3_RANK: Record<Phase3, number> = { probe: 0, plan: 1, teach: 2 };
|
||||
|
||||
/** Structural slice of a plan tree — anything with nodes + a current node. */
|
||||
export interface PhaseTree {
|
||||
nodes: ReadonlyArray<unknown>;
|
||||
currentNodeId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase of a tree, point-in-time: no nodes → probe, no current node →
|
||||
* plan, an occupied node → teach. Used for the chats summary (the stored
|
||||
* tree IS the session state there). Empty trees (emptyTree(), a failed
|
||||
* plan_init) read as probe — a seam only counts once a plan exists.
|
||||
* Minimal structural slice of a UI message for the phase scan: parts with
|
||||
* a state and (for concluded calls) an output.
|
||||
*/
|
||||
export function phaseOfTree(tree: PhaseTree | null | undefined): Phase3 {
|
||||
if (!tree || tree.nodes.length === 0) return "probe";
|
||||
return tree.currentNodeId === null ? "plan" : "teach";
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural slice of a UI message for the three-phase scan: parts
|
||||
* with a state and (when a plan op completed) its output.
|
||||
*/
|
||||
export interface Phase3Message {
|
||||
export interface PhaseMessage {
|
||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
|
||||
}
|
||||
|
||||
/** A successful plan-op output as it appears on the part (`PlanToolResult`). */
|
||||
interface PlanOutput {
|
||||
tree?: { nodes?: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* The three-phase boundary semantics over the TRANSCRIPT (the same data the
|
||||
* tree panel reads): scanning forward from 0 to `upToIndex` (default: the
|
||||
* last message), the phase advances probe → plan at the first plan part
|
||||
* with a NON-EMPTY tree available (a failed plan_init carries an empty tree
|
||||
* and never marks the seam), and plan → teach at the first tree output with
|
||||
* an occupied node. MONOTONIC by design: the phase only ever advances — a
|
||||
* node removal mid-teaching (currentNodeId → null) is an edit, not a phase
|
||||
* regression, so no "Plan" divider ever appears below teaching content.
|
||||
*
|
||||
* `phaseAt` (two-phase) stays as-is: the plan_init call is its seam and the
|
||||
* question dock's reveal logic leans on it. The two views differ only in
|
||||
* the window between plan_init and the first plan_set_current, where
|
||||
* three-phase reads "plan".
|
||||
*/
|
||||
export function phaseAt3(messages: readonly Phase3Message[], upToIndex?: number): Phase3 {
|
||||
/** The phase at `upToIndex` (inclusive; default: the last message). */
|
||||
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
|
||||
let phase: Phase3 = "probe";
|
||||
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||
for (let i = 0; i <= last; i++) {
|
||||
const parts = messages[i]?.parts;
|
||||
if (!parts) continue;
|
||||
for (const part of parts) {
|
||||
if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue;
|
||||
const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined;
|
||||
if (!tree || !Array.isArray(tree.nodes)) continue;
|
||||
const candidate = phaseOfTree(tree);
|
||||
if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate;
|
||||
phase = advance(phase, messages[i]?.parts);
|
||||
}
|
||||
return phase;
|
||||
}
|
||||
|
||||
/** One message's advance of the phase (pure fold step). */
|
||||
function advance(
|
||||
phase: Phase3,
|
||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }> | undefined,
|
||||
): Phase3 {
|
||||
if (!parts) return phase;
|
||||
for (const part of parts) {
|
||||
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
|
||||
// plan → teach only via a successful plan_init. A marker out of order
|
||||
// (e.g. a legacy plan_init with no probe) advances nothing — the chain
|
||||
// never skips, and the rank table below only guards regressions.
|
||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||
phase = "plan";
|
||||
}
|
||||
if (phase === "plan" && part.type === "tool-plan_init" && part.state === "output-available") {
|
||||
const tree = (part.output as PlanOutput | undefined)?.tree;
|
||||
if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) phase = "teach";
|
||||
}
|
||||
}
|
||||
return phase;
|
||||
@@ -130,24 +71,15 @@ export function phaseAt3(messages: readonly Phase3Message[], upToIndex?: number)
|
||||
|
||||
/**
|
||||
* The phase at EVERY message index, in one forward fold — the transcript
|
||||
* dividers render one per boundary, so phaseAt3(m, i) per index would
|
||||
* rescan 0..i each time (quadratic); this is the same derivation, linear.
|
||||
* out[i] === phaseAt3(messages, i) for every index.
|
||||
* dividers render one per boundary, so phaseAt3(m, i) per index would rescan
|
||||
* 0..i each time (quadratic); this is the same derivation, linear.
|
||||
* out[i] === phaseAt3(messages, i) for every i.
|
||||
*/
|
||||
export function phaseTimeline(messages: readonly Phase3Message[]): Phase3[] {
|
||||
export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
|
||||
const out: Phase3[] = [];
|
||||
let phase: Phase3 = "probe";
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const parts = messages[i]?.parts;
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue;
|
||||
const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined;
|
||||
if (!tree || !Array.isArray(tree.nodes)) continue;
|
||||
const candidate = phaseOfTree(tree);
|
||||
if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate;
|
||||
}
|
||||
}
|
||||
phase = advance(phase, messages[i]?.parts);
|
||||
out.push(phase);
|
||||
}
|
||||
return out;
|
||||
|
||||
+28
-123
@@ -1,28 +1,23 @@
|
||||
/**
|
||||
* Plan-block emission (wave 4, the seam the dock/popup render against):
|
||||
* the PURE builder that turns a plan tree + transcript into the persisted
|
||||
* `data-plan-block` parts the chat renders in flow. Emission happens ONCE,
|
||||
* at persist time (turn-service seam) — blocks are frozen transcript
|
||||
* content (user decision, 2026-09-11), never derived at render:
|
||||
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
|
||||
* presentation seam. The plan renders IN FLOW once, at authoring — a full
|
||||
* `data-plan-block` attached to the assistant message that carries the
|
||||
* successful plan_init call. It is the presentation moment for the plan
|
||||
* (and the popup's entry point) — statuses on it are accurate at that
|
||||
* instant (current = null, everything upcoming — unless the authoring is a
|
||||
* mid-lesson rewrite, in which case the snapshot shows what was true then).
|
||||
*
|
||||
* - FULL block: attached to the assistant message carrying a successful
|
||||
* plan_init call — the whole DAG, snapshot statuses. Always emitted on
|
||||
* (re)authoring, even an identical rewrite: authoring is the event.
|
||||
* - NEIGHBORHOOD block: attached to the LAST assistant message when the
|
||||
* derived plan state MOVED since the newest existing block — current
|
||||
* shifted or a check passed (done grew). A resolved FAILED check also
|
||||
* moves: done doesn't grow, but the check is no longer out, so current
|
||||
* falls back to the first eligible node — the block reflects that
|
||||
* standing. The as-of state is read from the newest block's FROZEN
|
||||
* SNAPSHOT (not re-derived from the transcript, which the client
|
||||
* re-posts with answered quizzes mutated in place) and compared with
|
||||
* the state derived now — no extra state channel exists.
|
||||
* The neighborhood-block machinery (frozen fragments on plan movement) is
|
||||
* RETIRED: position derives live from the transcript (derivePlanStatuses —
|
||||
* calls, not results), and frozen snapshots can't represent live movement.
|
||||
* The persistent DAG window (the UI wave) is the successor — always
|
||||
* present, auto-moving with the lesson.
|
||||
*
|
||||
* Idempotent by construction: a message that already carries a plan-block
|
||||
* part is never given another (persist replays / retry paths are safe).
|
||||
*/
|
||||
|
||||
import type { PlanBlockData, PlanNode, PlanTree } from "./contracts.ts";
|
||||
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
||||
import { derivePlanStatuses } from "./contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
@@ -34,19 +29,7 @@ export interface PlanBlockPart {
|
||||
data: PlanBlockData;
|
||||
}
|
||||
|
||||
/** Nodes/edges of the one-level neighborhood: current + direct parents +
|
||||
* direct children, and the edges among exactly those nodes. */
|
||||
export function neighborhood(tree: PlanTree, current: string): { nodes: PlanNode[]; edges: Array<{ from: string; to: string }> } {
|
||||
const parents = tree.edges.filter((e) => e.to === current).map((e) => e.from);
|
||||
const children = tree.edges.filter((e) => e.from === current).map((e) => e.to);
|
||||
const ids = new Set([current, ...parents, ...children]);
|
||||
return {
|
||||
nodes: tree.nodes.filter((n) => ids.has(n.id)),
|
||||
edges: tree.edges.filter((e) => ids.has(e.from) && ids.has(e.to)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Does this message carry a successful plan_init (the full-block trigger)? */
|
||||
/** Does this message carry a successful plan_init (the trigger)? */
|
||||
function hasSuccessfulPlanInit(message: UIMessage): boolean {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool-plan_init") continue;
|
||||
@@ -56,114 +39,36 @@ function hasSuccessfulPlanInit(message: UIMessage): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The plan state as derived from a message slice — the diff key. `checked`
|
||||
* = every node with a tagged quiz out or answered (a new check IS a plan
|
||||
* event: the block's glowing current follows it). */
|
||||
function planState(messages: readonly UIMessage[], tree: PlanTree) {
|
||||
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
||||
const statuses = derivePlanStatuses(tree, messages);
|
||||
const checked = new Set<string>();
|
||||
for (const m of messages) {
|
||||
for (const part of m.parts) {
|
||||
if (part.type !== "tool-quiz") continue;
|
||||
const nodeId = (part as { input?: { nodeId?: unknown } }).input?.nodeId;
|
||||
if (typeof nodeId === "string" && nodeId !== "") checked.add(nodeId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
current: statuses.current,
|
||||
done: [...statuses.done].sort().join(","),
|
||||
checked: [...checked].sort().join(","),
|
||||
};
|
||||
}
|
||||
|
||||
/** The messages up to and including the newest plan-block carrier — the
|
||||
* "as of last block" slice. Null when no block exists yet. */
|
||||
function sliceAsOfLastBlock(messages: readonly UIMessage[]): UIMessage[] | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.parts.some((p) => p.type === "data-plan-block")) return messages.slice(0, i + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The as-of state read from the newest existing block's FROZEN SNAPSHOT —
|
||||
* not re-derived from the transcript (which the client re-posts with quiz
|
||||
* parts mutated in place: an answered check's part flips from
|
||||
* input-available to output-available, so a re-derivation would read the
|
||||
* post-answer state and the move would be invisible). The block is the
|
||||
* as-of record; that is what frozen snapshots are for. */
|
||||
function stateAsOfLastBlock(messages: readonly UIMessage[]): { current: string | null; done: string } | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
for (const part of messages[i]!.parts) {
|
||||
if (part.type !== "data-plan-block") continue;
|
||||
const data = (part as { data?: PlanBlockData }).data;
|
||||
if (!data) return null;
|
||||
return {
|
||||
current: data.current,
|
||||
done: data.nodes
|
||||
.filter((n) => n.status === "done")
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(","),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function snapshot(kind: PlanBlockData["kind"], tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData | null {
|
||||
const statuses = derivePlanStatuses(tree, messages);
|
||||
if (kind === "neighborhood" && statuses.current === null) return null;
|
||||
const scope = kind === "full" || statuses.current === null ? tree : neighborhood(tree, statuses.current);
|
||||
return {
|
||||
kind,
|
||||
kind: "full",
|
||||
at: new Date().toISOString(),
|
||||
current: statuses.current,
|
||||
nodes: scope.nodes.map((n) => ({
|
||||
nodes: tree.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
status: statuses.current === n.id ? "current" : statuses.done.has(n.id) ? "done" : "upcoming",
|
||||
status: statuses.current === n.id ? "current" : statuses.visited.has(n.id) ? "visited" : "upcoming",
|
||||
})),
|
||||
edges: scope.edges.map((e) => ({ from: e.from, to: e.to })),
|
||||
edges: tree.edges.map((e) => ({ from: e.from, to: e.to })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a message list with plan-block parts — THE emission seam's pure
|
||||
* half. `messages` is the deduped list persistTurn is about to write;
|
||||
* `tree` is the record's current tree (the plan tool saved it during the
|
||||
* turn). Messages already carrying a block are never touched.
|
||||
* Enrich a message list with plan-presentation blocks — THE emission
|
||||
* seam's pure half. `messages` is the deduped list persistTurn is about to
|
||||
* write; `tree` is the record's current tree (the plan tool saved it during
|
||||
* the turn). Messages already carrying a block are never touched.
|
||||
*/
|
||||
export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] {
|
||||
if (!tree || tree.nodes.length === 0 || messages.length === 0) return messages;
|
||||
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||
if (!lastAssistant) return messages;
|
||||
|
||||
// neighborhood needed? the plan state moved since the newest existing block
|
||||
let moved = false;
|
||||
const asOf = stateAsOfLastBlock(messages);
|
||||
if (asOf === null) {
|
||||
moved = planState(messages, tree).checked !== ""; // first block: any plan event ever
|
||||
} else {
|
||||
const now = planState(messages, tree);
|
||||
moved = asOf.current !== now.current || asOf.done !== now.done;
|
||||
}
|
||||
|
||||
const blockId = `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
let fullAttached = false;
|
||||
return messages.map((m) => {
|
||||
if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m;
|
||||
if (hasSuccessfulPlanInit(m)) {
|
||||
const data = snapshot("full", tree, messages);
|
||||
if (!data) return m;
|
||||
fullAttached = true;
|
||||
return { ...m, parts: [...m.parts, { type: "data-plan-block", id: `${blockId}_f`, data } satisfies PlanBlockPart] };
|
||||
}
|
||||
if (moved && !fullAttached && m.id === lastAssistant.id) {
|
||||
const data = snapshot("neighborhood", tree, messages);
|
||||
if (!data) return m;
|
||||
return { ...m, parts: [...m.parts, { type: "data-plan-block", id: `${blockId}_n`, data } satisfies PlanBlockPart] };
|
||||
}
|
||||
return m;
|
||||
if (!hasSuccessfulPlanInit(m)) return m;
|
||||
return {
|
||||
...m,
|
||||
parts: [...m.parts, { type: "data-plan-block", id: blockId, data: snapshot(tree, messages) } satisfies PlanBlockPart],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+7
-101
@@ -28,7 +28,7 @@ export class PlanTreeError extends Error {
|
||||
|
||||
/** An empty, never-stored tree used as the seed before plan_init runs. */
|
||||
export function emptyTree(): PlanTree {
|
||||
return { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 };
|
||||
return { nodes: [], edges: [], version: 0 };
|
||||
}
|
||||
|
||||
// ─── validation helpers ──────────────────────────────────────────────────────
|
||||
@@ -122,105 +122,11 @@ export function createTree(nodes: PlanNode[], edges: PlanEdge[]): PlanTree {
|
||||
assertValidEdge(e.from, e.to, nodes, accepted);
|
||||
accepted.push({ from: e.from, to: e.to });
|
||||
}
|
||||
return { nodes: nodes.map((n) => ({ ...n })), edges: accepted, currentNodeId: null, doneNodeIds: [], version: 1 };
|
||||
return { nodes: nodes.map((n) => ({ ...n })), edges: accepted, version: 1 };
|
||||
}
|
||||
|
||||
/** Add a node, optionally creating edges parent → new node for each id in `parents`. */
|
||||
export function addNode(tree: PlanTree, node: PlanNode, parents?: string[]): PlanTree {
|
||||
assertValidNode(node);
|
||||
if (tree.nodes.some((n) => n.id === node.id)) {
|
||||
throw new PlanTreeError(`Duplicate node id "${node.id}" — a node with that id already exists in the tree.`);
|
||||
}
|
||||
const parentList = parents ?? [];
|
||||
if (parentList.includes(node.id)) {
|
||||
throw new PlanTreeError(`Node "${node.id}" can't list itself as a parent.`);
|
||||
}
|
||||
const newEdges: PlanEdge[] = [];
|
||||
for (const parent of parentList) {
|
||||
// Validates existence (including the new node itself as `to`),
|
||||
// self-loop, duplicate (against tree edges plus the new edges being
|
||||
// added), and acyclicity.
|
||||
assertValidEdge(parent, node.id, [...tree.nodes, node], [...tree.edges, ...newEdges]);
|
||||
newEdges.push({ from: parent, to: node.id });
|
||||
}
|
||||
return {
|
||||
...tree,
|
||||
nodes: [...tree.nodes, { ...node }],
|
||||
edges: [...tree.edges, ...newEdges],
|
||||
version: tree.version + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update a node's title and/or summary. The id must exist; ids are immutable. */
|
||||
export function updateNode(tree: PlanTree, id: string, patch: { title?: string; summary?: string }): PlanTree {
|
||||
const index = tree.nodes.findIndex((n) => n.id === id);
|
||||
if (index === -1) {
|
||||
throw new PlanTreeError(`Cannot update node "${id}" — it is not in the tree.`);
|
||||
}
|
||||
const current = tree.nodes[index]!;
|
||||
const next: PlanNode = { ...current };
|
||||
if (patch.title !== undefined) next.title = patch.title;
|
||||
if (patch.summary !== undefined) next.summary = patch.summary;
|
||||
assertValidNode(next);
|
||||
const nodes = tree.nodes.slice();
|
||||
nodes[index] = next;
|
||||
return { ...tree, nodes, version: tree.version + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node and cascade: every edge touching it (both directions),
|
||||
* currentNodeId (→ null if it pointed here), and its doneNodeIds entry.
|
||||
*/
|
||||
export function removeNode(tree: PlanTree, id: string): PlanTree {
|
||||
if (!tree.nodes.some((n) => n.id === id)) {
|
||||
throw new PlanTreeError(`Cannot remove node "${id}" — it is not in the tree.`);
|
||||
}
|
||||
return {
|
||||
...tree,
|
||||
nodes: tree.nodes.filter((n) => n.id !== id),
|
||||
edges: tree.edges.filter((e) => e.from !== id && e.to !== id),
|
||||
currentNodeId: tree.currentNodeId === id ? null : tree.currentNodeId,
|
||||
doneNodeIds: tree.doneNodeIds.filter((d) => d !== id),
|
||||
version: tree.version + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add a `requires` edge from → to. Rejects self-loops, duplicates, and cycles. */
|
||||
export function addEdge(tree: PlanTree, from: string, to: string): PlanTree {
|
||||
assertValidEdge(from, to, tree.nodes, tree.edges);
|
||||
return { ...tree, edges: [...tree.edges, { from, to }], version: tree.version + 1 };
|
||||
}
|
||||
|
||||
/** Remove an existing edge. Removing a nonexistent edge is an error. */
|
||||
export function removeEdge(tree: PlanTree, from: string, to: string): PlanTree {
|
||||
const index = tree.edges.findIndex((e) => e.from === from && e.to === to);
|
||||
if (index === -1) {
|
||||
throw new PlanTreeError(`Cannot remove edge "${from}" → "${to}" — no such edge exists.`);
|
||||
}
|
||||
return { ...tree, edges: tree.edges.filter((e) => e.from !== from || e.to !== to), version: tree.version + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set currentNodeId ("where we are"). Requires the node exists. Does NOT
|
||||
* touch doneNodeIds. Setting the already-current node is a true no-op (no
|
||||
* version bump).
|
||||
*/
|
||||
export function setCurrent(tree: PlanTree, id: string): PlanTree {
|
||||
if (!tree.nodes.some((n) => n.id === id)) {
|
||||
throw new PlanTreeError(`Cannot set current node "${id}" — it is not in the tree.`);
|
||||
}
|
||||
if (tree.currentNodeId === id) return tree;
|
||||
return { ...tree, currentNodeId: id, version: tree.version + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a node done. Requires the node exists; idempotent — marking an
|
||||
* already-done node is a true no-op (no version bump).
|
||||
*/
|
||||
export function markDone(tree: PlanTree, id: string): PlanTree {
|
||||
if (!tree.nodes.some((n) => n.id === id)) {
|
||||
throw new PlanTreeError(`Cannot mark node "${id}" done — it is not in the tree.`);
|
||||
}
|
||||
if (tree.doneNodeIds.includes(id)) return tree;
|
||||
return { ...tree, doneNodeIds: [...tree.doneNodeIds, id], version: tree.version + 1 };
|
||||
}
|
||||
// The per-node mutation ops (addNode/updateNode/removeNode/addEdge/removeEdge/
|
||||
// setCurrent/markDone) were deleted with the author-once protocol
|
||||
// (2026-09-03) and their dead exports removed with the 2026-09-11 pivot: the
|
||||
// tree carries structure ONLY — no progress fields exist to mutate
|
||||
// (docs/probe-teach-split.md §3). Restructuring is a plan_init rewrite.
|
||||
|
||||
+66
-45
@@ -107,47 +107,59 @@ export function quizInputProblems(input: {
|
||||
return problems;
|
||||
}
|
||||
|
||||
export const quizInputObject = z
|
||||
.object({
|
||||
question: z.string().describe("The single quiz question to ask. Ask exactly one question per tool call."),
|
||||
options: z
|
||||
.array(optionSchema)
|
||||
.min(2)
|
||||
.describe(
|
||||
"The answer options (2 or more). Options only — there is no free-text mode. " +
|
||||
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
|
||||
"Never add your own 'I don't know' option — that choice is added for you.",
|
||||
),
|
||||
multiSelect: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Set to true when more than one option is correct and the learner must select all of them."),
|
||||
correctAnswer: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.describe(
|
||||
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
||||
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
|
||||
"the learner is only correct if their selection matches this set exactly. " +
|
||||
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
|
||||
"A value that matches no option fails validation and the call is returned to you to fix.",
|
||||
),
|
||||
explanation: z
|
||||
.string()
|
||||
.describe(
|
||||
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
|
||||
"Use it to reinforce why the correct answer is correct.",
|
||||
),
|
||||
details: z.string().optional().describe("Optional extra context or instructions shown under the question."),
|
||||
nodeId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional plan-tree node id this question assesses — must EXACTLY match one of this session's " +
|
||||
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
|
||||
"Set it whenever the question targets a map node — the outcome drives the learner's live session map. " +
|
||||
"Omit for ad-hoc checks.",
|
||||
),
|
||||
})
|
||||
// The SHARED quiz authoring fields — everything except `nodeId`. The probe
|
||||
// and teach agents use DIFFERENT tools under the same name (docs/probe-teach-
|
||||
// split.md §1): the prober's quiz has NO nodeId field at all (there is no
|
||||
// map to position on), the teacher's adds it (the position signal — the
|
||||
// per-session tree validation chains on top in tools.ts). Grading rules
|
||||
// are identical, single-sourced here.
|
||||
export const quizInputBase = z.object({
|
||||
question: z.string().describe("The single quiz question to ask. Ask exactly one question per tool call."),
|
||||
options: z
|
||||
.array(optionSchema)
|
||||
.min(2)
|
||||
.describe(
|
||||
"The answer options (2 or more). Options only — there is no free-text mode. " +
|
||||
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
|
||||
"Never add your own 'I don't know' option — that choice is added for you.",
|
||||
),
|
||||
multiSelect: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Set to true when more than one option is correct and the learner must select all of them."),
|
||||
correctAnswer: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.describe(
|
||||
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
||||
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
|
||||
"the learner is only correct if their selection matches this set exactly. " +
|
||||
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
|
||||
"A value that matches no option fails validation and the call is returned to you to fix.",
|
||||
),
|
||||
explanation: z
|
||||
.string()
|
||||
.describe(
|
||||
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
|
||||
"Use it to reinforce why the correct answer is correct.",
|
||||
),
|
||||
details: z.string().optional().describe("Optional extra context or instructions shown under the question."),
|
||||
});
|
||||
|
||||
// The TEACH-side quiz object: the shared base + the plan-position field.
|
||||
// `nodeId` means "this question is ABOUT this node" — a position signal,
|
||||
// never a completion signal (docs/probe-teach-split.md §3).
|
||||
export const quizInputObject = quizInputBase.extend({
|
||||
nodeId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional plan-tree node id this question is about — must EXACTLY match one of this session's " +
|
||||
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
|
||||
"Tag every question that targets a map node: the lesson's position on the map derives from these tags " +
|
||||
"(the node the latest tagged question is about is where the map stands; earlier ones count as visited). " +
|
||||
"Omit for quick ad-hoc checks that don't target a node.",
|
||||
),
|
||||
})
|
||||
// The cross-field authoring contract (the Gate-3 hardening): a quiz whose
|
||||
// correctAnswer can't be graded NEVER reaches the learner. The SDK validates
|
||||
// client-side tool inputs against this schema at the call boundary and, on
|
||||
@@ -155,10 +167,11 @@ export const quizInputObject = z
|
||||
// the teacher retries with a fixed quiz in the same turn. Schema-valid ⇔
|
||||
// gradeable: the rules are exactly what the grading path accepts.
|
||||
//
|
||||
// NOTE the split: the raw object (pre-refinement) is exported for
|
||||
// composition — tools.ts questionToolsFor chains the per-session plan-nodeId
|
||||
// validation (it reads the live chat record, which this core never touches)
|
||||
// on top of this shared refinement.
|
||||
// NOTE the split: the raw objects (pre-refinement) are exported for
|
||||
// composition — tools.ts teachQuestionTools chains the per-session
|
||||
// plan-nodeId validation (it reads the live chat record, which this core
|
||||
// never touches) on top of this shared refinement, and the PROBE quiz
|
||||
// (no nodeId at all) refines quizInputBase directly.
|
||||
export const quizInputSchema = quizInputObject
|
||||
.superRefine((input, ctx) => {
|
||||
for (const problem of quizInputProblems(input)) {
|
||||
@@ -166,6 +179,14 @@ export const quizInputSchema = quizInputObject
|
||||
}
|
||||
});
|
||||
|
||||
/** The PROBE-side quiz schema: the shared authoring contract, no nodeId
|
||||
* field at all — the session's map doesn't exist yet. */
|
||||
export const probeQuizInputSchema = quizInputBase.superRefine((input, ctx) => {
|
||||
for (const problem of quizInputProblems(input)) {
|
||||
ctx.addIssue({ code: "custom", message: problem });
|
||||
}
|
||||
});
|
||||
|
||||
export const askInputSchema = z
|
||||
.object({
|
||||
// No `mode` param — the pi original has none: the mode is inferred from
|
||||
|
||||
+12
-1
@@ -1,5 +1,13 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { DemoToolInput, PlanBlockData, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts";
|
||||
import type {
|
||||
DemoToolInput,
|
||||
PlanBlockData,
|
||||
ProbeDoneInput,
|
||||
ProbeDoneResult,
|
||||
ResearchToolInput,
|
||||
SubagentArtifactData,
|
||||
VisualToolInput,
|
||||
} from "./contracts.ts";
|
||||
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
|
||||
|
||||
/**
|
||||
@@ -17,6 +25,9 @@ import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question
|
||||
export type TeacherUITools = {
|
||||
quiz: { input: QuizToolInput; output: QuestionAnswerData };
|
||||
ask_user_question: { input: AskToolInput; output: QuestionAnswerData };
|
||||
/** The prober's conclusion tool (docs/probe-teach-split.md §2): the
|
||||
* input IS the learner assessment; the output confirms the handoff. */
|
||||
probe_done: { input: ProbeDoneInput; output: ProbeDoneResult };
|
||||
research: { input: ResearchToolInput; output: string };
|
||||
demo: { input: DemoToolInput; output: string };
|
||||
visual: { input: VisualToolInput; output: string };
|
||||
|
||||
Reference in New Issue
Block a user