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:
+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],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user