fix: verdict death — plan→teach seam inferred from first nodeId-tagged quiz
The Accept/Decline verdict gate is gone (owner ruling 2026-09-26): presenting the plan ends the teacher's turn; the learner's next message is a natural reply. Teaching begins at the first quiz call tagged with a nodeId (probe quizzes carry none) — both folds (view phaseAt3, native phaseAt3Lh) advance on the call itself, and the teach divider renders before that whole assistant block. No backwards compat: verdict markers, the verdict input event, optimistic appends, composer buttons, and projection all deleted; prompts and docs rewritten to the natural flow.
This commit is contained in:
@@ -50,9 +50,9 @@ Two parts:
|
||||
|
||||
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|
||||
|
||||
**Presenting ENDS your turn.** The moment the plan is presented, his interface shows two verdict buttons — Accept plan / Decline — and the session waits for his word before any teaching is sanctioned. Do not ask a check or teach before it arrives.
|
||||
**Presenting ENDS your turn.** The plan is presented in prose and map, and then you stop — his next message is the natural next move. Do not ask a check or teach in the same turn as presenting.
|
||||
|
||||
- **Acceptance** arrives as a message like "[Plan accepted] — begin teaching." Begin the first node.
|
||||
- **Decline** arrives as a revision request. Ask what he'd like changed if it isn't clear, fix the map (`plan_init` again — a wholesale rewrite), present again, and wait once more.
|
||||
- If his reply asks for changes, revise: fix the map (`plan_init` again — a wholesale rewrite), present again, and end your turn once more.
|
||||
- Otherwise his reply moves the lesson forward — begin the first node.
|
||||
|
||||
The verdict is his alone to give — never ask a question whose answer would assume it, and never start teaching into an unaccepted plan. A plan he interacts with mid-lesson (a `plan_init` rewrite during teaching) needs no fresh verdict: acceptance happens once, at the first presentation.
|
||||
Never start teaching in the same turn as presenting the plan. A plan rewrite mid-lesson needs no fresh presentation gate: just re-call `plan_init` and carry on.
|
||||
@@ -181,7 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
steeringMode: "all",
|
||||
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
|
||||
// THE app seam — markers flatten to their canonical user text
|
||||
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced),
|
||||
// (HANDOFF_TEXT / steer text, all single-sourced),
|
||||
// UI-only markers (plan-block) drop, the app's id fields strip, and
|
||||
// an abort's dangling question call gets its INTERRUPTED result. It
|
||||
// must never throw (pi's convertToLlm contract).
|
||||
|
||||
@@ -149,30 +149,6 @@ export interface TurnUsage {
|
||||
* block renders current node + direct parents + direct children in its own
|
||||
* small container, the current node glowing — clearly a fragment, not the
|
||||
* whole plan. Clicking either opens the full plan in a popup. */
|
||||
/** The learner's plan verdict (docs/probe-teach-split.md §4): a data part
|
||||
* riding its own user message — the ONLY thing on that message. It is THE
|
||||
* plan→teach phase seam (phaseAt3 reads accepted from the transcript), and
|
||||
* both verdicts are real transcript events: accept sanctions teaching,
|
||||
* decline asks for revision. The part is the message TYPE (the "custom
|
||||
* message" scheme, 2026-09-12): the message carries no human-authored text,
|
||||
* the model-facing wording is owned by toLlmMessages (native-messages.ts
|
||||
* flattens it to verdictText below), and the UI renders the verdict
|
||||
* marker — never a user bubble. A learner who also typed a note sends the
|
||||
* note as its own real user message first; the verdict message follows. */
|
||||
export interface PlanVerdictData {
|
||||
/** true = accept (sanction to teach); false = decline (revise the plan). */
|
||||
accepted: boolean;
|
||||
/** ISO timestamp of the click. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The model-facing text of a verdict message — single-sourced here so the
|
||||
* adapter, the tests, and reality can't drift apart. */
|
||||
export function verdictText(accepted: boolean): string {
|
||||
return accepted
|
||||
? "The learner accepted the plan — begin teaching."
|
||||
: "The learner would like the plan revised before teaching begins.";
|
||||
}
|
||||
|
||||
/** The model-facing text of the probe→teach handoff wake (the custom-message
|
||||
* scheme): the wake message carries only the data-probe-handoff part, and
|
||||
@@ -185,14 +161,16 @@ export function verdictText(accepted: boolean): string {
|
||||
* fragment asks for. So the wake paces it: reason out the optimal way to
|
||||
* teach THIS person THIS thing BEFORE plan_init. Honest to the machinery
|
||||
* — plan_init is still the first tool, the plan map still the first
|
||||
* artifact, and presenting still waits for the verdict. */
|
||||
* artifact, and presenting still ends the turn (the verdict death,
|
||||
* 2026-09-26: the learner's next message is a natural reply — a change
|
||||
* request or the go-ahead). */
|
||||
export const HANDOFF_TEXT =
|
||||
"The probe is concluded — the learner assessment is recorded. " +
|
||||
"This is the session's highest-leverage moment, and nothing has been authored yet — take your time. " +
|
||||
"Read his assessment and the probing transcript, and reason out the optimal way to teach this person this thing: " +
|
||||
"what he already holds and where each strand runs out, the misconceptions to route around, " +
|
||||
"and the minimal tree from what he holds as obviously true to his goal — so every step lands as obviously true. " +
|
||||
"When the plan is genuinely reasoned out, call plan_init and present it, then wait for his verdict.";
|
||||
"When the plan is genuinely reasoned out, call plan_init, present it, and end your turn — his next message either asks for changes or moves the lesson forward.";
|
||||
|
||||
|
||||
export interface PlanBlockData {
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
* built client-side exactly as today; a dismissal is an answer
|
||||
* carrying its flag). Server-side this becomes the call's
|
||||
* native tool result — fork 1's normal agent history.
|
||||
* verdict — the plan verdict (accept/decline); an optional note is the
|
||||
* learner's own real user message, sent first, exactly the
|
||||
* two-real-messages scheme.
|
||||
* continue — run the next turn on the current record (mount
|
||||
* continuations, job-settle re-syncs: the record ends at an
|
||||
* answered question with no teacher reaction yet).
|
||||
*
|
||||
* (The plan verdict input died 2026-09-26 with the verdict itself — the
|
||||
* learner's post-plan reply is a natural text message.)
|
||||
*
|
||||
* Server-originated delivery (the wake pipeline's steers) is NOT a client
|
||||
* input — the handler accepts it on the same path (TurnInput) so every
|
||||
* turn rides one composition.
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
danglingQuestionOf,
|
||||
newAnswerResult,
|
||||
newUserMessage,
|
||||
newVerdictMessage,
|
||||
} from "./native-messages.ts";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
|
||||
@@ -39,7 +38,6 @@ import { QUESTION_TOOLS } from "./pending.ts";
|
||||
export type ChatInput =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
||||
| { type: "verdict"; accepted: boolean; note?: string }
|
||||
| { type: "continue" };
|
||||
|
||||
/** A wake-pipeline steer delivery — same turn composition, server-origin.
|
||||
@@ -73,12 +71,6 @@ export function parseChatInput(input: unknown): ChatInput {
|
||||
throw new ChatInputError("answer input requires the graded answer object");
|
||||
}
|
||||
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
|
||||
case "verdict":
|
||||
if (typeof i.accepted !== "boolean") throw new ChatInputError("verdict input requires accepted");
|
||||
if (i.note !== undefined && typeof i.note !== "string") {
|
||||
throw new ChatInputError("verdict note must be a string");
|
||||
}
|
||||
return { type: "verdict", accepted: i.accepted, note: i.note as string | undefined };
|
||||
case "continue":
|
||||
return { type: "continue" };
|
||||
default:
|
||||
@@ -106,14 +98,6 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
|
||||
}
|
||||
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
|
||||
}
|
||||
case "verdict": {
|
||||
const appends: LhMessage[] = [];
|
||||
if (input.note && input.note.trim().length > 0) {
|
||||
appends.push(newUserMessage(input.note));
|
||||
}
|
||||
appends.push(newVerdictMessage(input.accepted, new Date().toISOString()));
|
||||
return appends;
|
||||
}
|
||||
case "continue":
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* sees it (verified single site, below).
|
||||
*
|
||||
* UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message
|
||||
* taxonomy): verdict markers, the probe→teach handoff, sub-agent steers,
|
||||
* taxonomy): the probe→teach handoff, sub-agent steers,
|
||||
* and plan blocks are LhMarkerMessage — this app's declaration-merged
|
||||
* member of pi-agent-core's CustomAgentMessages extension point, shaped
|
||||
* exactly like pi's own CustomMessage ({role:"custom", customType,
|
||||
@@ -36,7 +36,7 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import { HANDOFF_TEXT, verdictText } from "./contracts/chat.ts";
|
||||
import { HANDOFF_TEXT } from "./contracts/chat.ts";
|
||||
|
||||
/** The hard-invariant wording for an aborted turn's unanswered calls —
|
||||
* carried over verbatim from the pre-native era so the model's view
|
||||
@@ -58,7 +58,7 @@ export interface LhMarkerMessage {
|
||||
/** Record identity (pi messages carry no id — see the file header). */
|
||||
id: string;
|
||||
role: "custom";
|
||||
customType: "verdict" | "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
|
||||
customType: "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
|
||||
/** The model-facing text ("" for plan blocks — UI-only). */
|
||||
content: string;
|
||||
display: boolean;
|
||||
@@ -125,21 +125,6 @@ export function newAnswerResult(
|
||||
};
|
||||
}
|
||||
|
||||
/** The plan verdict (the plan→teach seam): marker only — the model-facing
|
||||
* wording is owned by verdictText (core/contracts/chat.ts), the UI
|
||||
* renders the quiet accept/decline line off details. */
|
||||
export function newVerdictMessage(accepted: boolean, at: string): LhMarkerMessage {
|
||||
return {
|
||||
id: newMessageId(),
|
||||
role: "custom",
|
||||
customType: "verdict",
|
||||
content: verdictText(accepted),
|
||||
display: true,
|
||||
details: { accepted, at },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The probe→teach handoff wake: scaffolding, not speech — the model reads
|
||||
* HANDOFF_TEXT, the UI renders the quiet system line. */
|
||||
export function newHandoffMessage(chatId: string): LhMarkerMessage {
|
||||
|
||||
+57
-37
@@ -1,29 +1,36 @@
|
||||
/**
|
||||
* 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 event:
|
||||
* occupancy. The probe→teach agent split (2026-09-11) gave the first
|
||||
* boundary a transcript event; the second is INFERRED (owner ruling
|
||||
* 2026-09-26, the verdict death):
|
||||
*
|
||||
* probe — until the prober's `probe_done` call CONCLUDES (a clean
|
||||
* toolResult for it; the assessment rides the call's arguments)
|
||||
* plan — probe concluded, until the learner ACCEPTS the presented
|
||||
* plan (the verdict marker with accepted:true — the explicit
|
||||
* verdict gate; plan_init itself is only the PRESENTATION and
|
||||
* moves no phase; a declined verdict stays in plan)
|
||||
* teach — teaching is sanctioned
|
||||
* plan — probe concluded, until teaching actually BEGINS
|
||||
* teach — the first quiz tool call tagged with a `nodeId` (a question
|
||||
* ABOUT a plan node — probe quizzes carry no nodeId in their
|
||||
* schema, so nothing false-triggers). The CALL is the seam, not
|
||||
* its answer: the block that poses the first node question is
|
||||
* where teaching starts.
|
||||
*
|
||||
* There is no verdict gate anymore: presenting the plan ends the teacher's
|
||||
* turn, and the learner's next message is a natural reply (a change
|
||||
* request, or the go-ahead). The live indicator LAGS by design (owner
|
||||
* accepted): "teach" shows only once the first node's question is posed.
|
||||
* A teacher that never tags a quiz would sit in "plan" visually —
|
||||
* off-doctrine (tagging is prompt-mandated and nodeId is call-validated).
|
||||
*
|
||||
* 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).
|
||||
* is GONE, as is the verdict-marker fold: no backwards compatibility
|
||||
* (legacy sessions are wiped data).
|
||||
*
|
||||
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
|
||||
* VIEW messages (the client's dividers and dock — part shapes, pure and
|
||||
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
|
||||
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach
|
||||
* at the verdict marker. Both are strictly sequential; the two derivations
|
||||
* agree on every settled record (a result/marker message projects into the
|
||||
* same view boundary).
|
||||
* at the assistant message carrying the first nodeId-tagged quiz CALL.
|
||||
* Both are strictly sequential; the two derivations agree on every settled
|
||||
* record (a call's part projects into the same view boundary).
|
||||
*
|
||||
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
||||
*/
|
||||
@@ -33,10 +40,22 @@ export type Phase3 = "probe" | "plan" | "teach";
|
||||
|
||||
/**
|
||||
* Minimal structural slice of a UI message for the phase scan: parts with
|
||||
* a state and (for concluded calls) an output.
|
||||
* a state, an input (a tool call's parsed arguments), and (for concluded
|
||||
* calls) an output.
|
||||
*/
|
||||
export interface PhaseMessage {
|
||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
|
||||
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>;
|
||||
}
|
||||
|
||||
/** A quiz call's part input carries `nodeId` iff it is tagged (teach-only —
|
||||
* the probe quiz schema has no nodeId field). */
|
||||
function taggedNodeId(input: unknown): boolean {
|
||||
return (
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
typeof (input as { nodeId?: unknown }).nodeId === "string" &&
|
||||
(input as { nodeId: string }).nodeId.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** The phase at `upToIndex` (inclusive; default: the last message). */
|
||||
@@ -52,23 +71,18 @@ export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number):
|
||||
/** One message's advance of the phase (pure fold step). */
|
||||
function advance(
|
||||
phase: Phase3,
|
||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }> | undefined,
|
||||
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; 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 the learner's plan acceptance (data-verdict
|
||||
// with accepted:true). A marker out of order advances nothing — the
|
||||
// chain never skips. plan_init PRESENTS the map; only the verdict
|
||||
// sanctions teaching (a decline stays in plan).
|
||||
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
|
||||
// not its answer — an errored-and-retried call still proves teaching
|
||||
// began, and the retry lands in the same group anyway).
|
||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||
phase = "plan";
|
||||
}
|
||||
if (
|
||||
phase === "plan" &&
|
||||
part.type === "data-verdict" &&
|
||||
(part as { data?: { accepted?: unknown } }).data?.accepted === true
|
||||
) {
|
||||
if (phase === "plan" && part.type === "tool-quiz" && taggedNodeId(part.input)) {
|
||||
phase = "teach";
|
||||
}
|
||||
}
|
||||
@@ -94,11 +108,12 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
|
||||
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
|
||||
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
/** The phase at `upToIndex` (inclusive; default: the last message) of a
|
||||
* NATIVE record. probe→plan advances at the probe_done call's clean
|
||||
* toolResult; plan→teach at the verdict marker (details.accepted true). */
|
||||
* toolResult; plan→teach at the assistant message carrying the first
|
||||
* nodeId-tagged quiz CALL (mirrors the view fold — same seam, native
|
||||
* shape). */
|
||||
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
|
||||
let phase: Phase3 = "probe";
|
||||
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||
@@ -106,7 +121,7 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
|
||||
const m = messages[i]!;
|
||||
const role = (m as { role?: string }).role;
|
||||
if (role === "toolResult") {
|
||||
const result = m as ToolResultMessage;
|
||||
const result = m as { toolName?: string; isError?: boolean };
|
||||
if (
|
||||
phase === "probe" &&
|
||||
result.toolName === "probe_done" &&
|
||||
@@ -114,14 +129,19 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
|
||||
) {
|
||||
phase = "plan";
|
||||
}
|
||||
} else if (role === "custom") {
|
||||
const marker = m as { customType?: string; details?: { accepted?: unknown } };
|
||||
if (
|
||||
phase === "plan" &&
|
||||
marker.customType === "verdict" &&
|
||||
marker.details?.accepted === true
|
||||
) {
|
||||
phase = "teach";
|
||||
} else if (role === "assistant") {
|
||||
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
|
||||
if (phase === "plan") {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block.type === "toolCall" &&
|
||||
block.name === "quiz" &&
|
||||
taggedNodeId(block.arguments)
|
||||
) {
|
||||
phase = "teach";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -36,7 +36,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"
|
||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||
import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts";
|
||||
import type { PlanBlockData } from "./contracts/chat.ts";
|
||||
|
||||
/** A view part — the loose part vocabulary the renderers consume (the same
|
||||
* shape names the pre-Wave-D UIMessage dialect used, so the renderer
|
||||
@@ -248,18 +248,9 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
|
||||
// rendered nothing) is dropped from the view — the record keeps it.
|
||||
}
|
||||
|
||||
/** A marker's projected message (verdict/handoff/steers) or splice (plan block). */
|
||||
/** A marker's projected message (handoff/steers) or splice (plan block). */
|
||||
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
||||
switch (marker.customType) {
|
||||
case "verdict": {
|
||||
const data = marker.details as PlanVerdictData;
|
||||
out.push({
|
||||
id: marker.id,
|
||||
role: "user",
|
||||
parts: [{ type: "data-verdict", id: `vd_${marker.id}`, data }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "probe-handoff": {
|
||||
out.push({
|
||||
id: marker.id,
|
||||
|
||||
Reference in New Issue
Block a user