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