practice record mechanics: active-problem fold, thinking channel, deferred conclude

The native shapes beneath the practice tools (owner design 2026-10-04):
- activeProblemOf — the conclude-before-next-problem gate's fold (a
  conclude call OR a dismissal marker deactivates; the pose-time ack
  never does — nudges keep the card up).
- The thinking input: {problemCallId, commit, text} → practice-thinking
  marker; the label (THINKING_HINT/COMMIT_LABEL, single-sourced in
  contracts) is the only register difference the model sees.
- Ask-style problem dismissal: silent route appends the UI-only marker;
  the user's NEXT input prepends the synthesized conclude pair (call
  outcome:'dismissed' + shared ack) so the agent reads the discard in
  its own vocabulary and the slot frees.
- toLlmMessages: dismissal marker filtered; pair passes natively.
This commit is contained in:
amosblomqvist
2026-10-04 21:41:29 +03:00
parent 7dcb006cf3
commit eb7454900c
4 changed files with 512 additions and 8 deletions
+16
View File
@@ -179,6 +179,22 @@ export const HANDOFF_TEXT =
"Take your time here and really think this through. " +
"When it's genuinely reasoned out, call plan_init and present the plan; I'll accept it or ask for changes.";
// ─── Practice sessions (owner, 2026-10-04) — marker + ack texts ──────────────
// Single-sourced here per the marker law: the model-facing text of every
// custom marker lives in core/contracts, never in the flattening seam.
/** The model-facing register of a thinking submission (newThinkingMessage
* prefixes the learner's text with this label). Hints and commits ride
* one channel — the label is the ONLY difference the model sees. */
export const THINKING_HINT_LABEL = "Thinking trace (working, not committed)";
export const THINKING_COMMIT_LABEL = "Committed answer";
/** The conclude tool's ack — shared by the executed call (agent layer) and
* the dismissal-synthesized pair (input-events), so a concluded problem
* reads identically to the model however it concluded. */
export const CONCLUDE_ACK_TEXT =
"Problem concluded — the composer is free; pose the next problem or wrap up.";
export interface PlanBlockData {
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
+62 -5
View File
@@ -28,9 +28,14 @@
import type { QuestionAnswerData } from "./question-core.ts";
import type { LhMessage } from "./native-messages.ts";
import {
activeProblemOf,
concludedByDismissalPair,
danglingQuestionOf,
newAnswerResult,
newProblemDismissedMarker,
newThinkingMessage,
newUserMessage,
pendingProblemDismissalOf,
} from "./native-messages.ts";
import { QUESTION_TOOLS, toolQuestionKind } from "./pending.ts";
@@ -38,7 +43,12 @@ import { QUESTION_TOOLS, toolQuestionKind } from "./pending.ts";
export type ChatInput =
| { type: "text"; text: string }
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
| { type: "continue" };
| { type: "continue" }
/** The practice dock's two buttons (owner, 2026-10-04): the learner's
* thinking on the ACTIVE problem — commit=false is the hint request
* (working, not committed), commit=true the answer commit. One
* problem may carry many submissions; each is its own turn. */
| { type: "thinking"; problemCallId: string; commit: boolean; text: string };
/** A wake-pipeline steer delivery — same turn composition, server-origin.
* Wave C (2026-09-26): the idle wake carries EVERY queued marker as its
@@ -56,12 +66,23 @@ export type TurnInput = ChatInput | SteerInput;
* BE a dismissal: the silent path must never inject a live answer
* wake-free. Parallel question calls each get their own dismiss event;
* results reference their calls by id, so the appends stay independent. */
export type DismissInput = { type: "dismiss"; toolCallId: string; answer: QuestionAnswerData };
export type DismissInput =
| { type: "dismiss"; toolCallId: string; answer: QuestionAnswerData }
/** The problem discard (practice, owner 2026-10-04): ask-style — the
* silent route appends the UI-only marker and wakes nothing; the
* conclude pair rides the user's NEXT input (inputAppends). */
| { type: "dismiss-problem"; problemCallId: string };
/** Parse and validate a dismissal event off a request body. */
export function parseDismissInput(input: unknown): DismissInput {
if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object");
const i = input as Record<string, unknown>;
if (i.type === "dismiss-problem") {
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
throw new ChatInputError("dismiss-problem input requires problemCallId");
}
return { type: "dismiss-problem", problemCallId: i.problemCallId };
}
if (i.type !== "dismiss") throw new ChatInputError(`unknown input type: ${String(i.type)}`);
if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) {
throw new ChatInputError("dismiss input requires toolCallId");
@@ -100,8 +121,16 @@ export function parseUserSteerInput(input: unknown): UserSteerInput {
}
/** The native appends a dismissal makes (pure — same orphan discipline
* as answers: the call must be a DANGLING question call). */
* as answers: the call must be a DANGLING question call, or — the
* practice variant — the ACTIVE problem). */
export function dismissAppends(record: { messages: LhMessage[] }, input: DismissInput): LhMessage[] {
if (input.type === "dismiss-problem") {
const active = activeProblemOf(record.messages);
if (!active || active.toolCallId !== input.problemCallId) {
throw new ChatInputError("dismiss-problem input targets no active problem");
}
return [newProblemDismissedMarker(input.problemCallId)];
}
const pending = danglingQuestionOf(record.messages);
if (!pending || pending.toolCallId !== input.toolCallId) {
throw new ChatInputError("dismiss input targets no pending question call");
@@ -135,6 +164,17 @@ export function parseChatInput(input: unknown): ChatInput {
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
case "continue":
return { type: "continue" };
case "thinking":
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
throw new ChatInputError("thinking input requires problemCallId");
}
if (typeof i.commit !== "boolean") {
throw new ChatInputError("thinking input requires commit (boolean)");
}
if (typeof i.text !== "string" || i.text.trim().length === 0) {
throw new ChatInputError("thinking input requires non-empty text");
}
return { type: "thinking", problemCallId: i.problemCallId, commit: i.commit, text: i.text };
default:
throw new ChatInputError(`unknown input type: ${String(i.type)}`);
}
@@ -152,7 +192,7 @@ export function parseChatInput(input: unknown): ChatInput {
export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] {
switch (input.type) {
case "text":
return [newUserMessage(input.text)];
return [...dismissalConclusion(record), newUserMessage(input.text)];
case "answer": {
const pending = danglingQuestionOf(record.messages);
if (!pending || pending.toolCallId !== input.toolCallId) {
@@ -161,10 +201,27 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
}
case "continue":
return [];
return dismissalConclusion(record);
case "thinking": {
const active = activeProblemOf(record.messages);
if (!active || active.toolCallId !== input.problemCallId) {
throw new ChatInputError("thinking input targets no active problem");
}
return [...dismissalConclusion(record), newThinkingMessage(input.problemCallId, input.commit, input.text)];
}
}
}
/** The deferred conclude synthesis (owner ruling, 2026-10-04): a
* problem-dismissal marker waits for the user's next input — the pair
* (synthetic conclude call + ack) rides ahead of it, so the agent reads
* the dismissal through its own vocabulary and the slot frees. Pure;
* empty when nothing dismissals pend. */
function dismissalConclusion(record: { messages: LhMessage[] }): LhMessage[] {
const problemCallId = pendingProblemDismissalOf(record.messages);
return problemCallId ? concludedByDismissalPair(problemCallId) : [];
}
/** Is this tool name a question tool (dock-owned, answer-gated)? */
export function isQuestionTool(name: string): boolean {
return (QUESTION_TOOLS as readonly string[]).includes(name);
+170 -3
View File
@@ -36,7 +36,12 @@
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 } from "./contracts/chat.ts";
import {
CONCLUDE_ACK_TEXT,
HANDOFF_TEXT,
THINKING_COMMIT_LABEL,
THINKING_HINT_LABEL,
} from "./contracts/chat.ts";
import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts";
/** The hard-invariant wording for an aborted turn's unanswered calls —
@@ -59,7 +64,13 @@ export interface LhMarkerMessage {
/** Record identity (pi messages carry no id — see the file header). */
id: string;
role: "custom";
customType: "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
customType:
| "probe-handoff"
| "subagent-steer"
| "subagent-abort"
| "plan-block"
| "practice-thinking"
| "problem-dismissed";
/** The model-facing text ("" for plan blocks — UI-only). */
content: string;
display: boolean;
@@ -169,6 +180,160 @@ export function newPlanBlockMessage(
};
}
// ─── practice sessions (owner, 2026-10-04) ───────────────────────────────────
/** The practice agent's two tools (the conclude-before-next-problem gate's
* vocabulary — the tool-execute layer owns the tools themselves). */
export const PRACTICE_PROBLEM_TOOL = "problem";
export const PRACTICE_CONCLUDE_TOOL = "conclude";
/** The learner's thinking submission (the practice dock's two buttons):
* a user-register message the model reads WITH its label (hint vs
* commit — the register is the whole difference), while `details` keeps
* the raw payload for the transcript card. One problem may carry many
* submissions. */
export function newThinkingMessage(problemCallId: string, commit: boolean, text: string): LhMarkerMessage {
const label = commit ? THINKING_COMMIT_LABEL : THINKING_HINT_LABEL;
return {
id: newMessageId(),
role: "custom",
customType: "practice-thinking",
content: `${label}:\n${text}`,
display: true,
details: { problemCallId, commit, text },
timestamp: Date.now(),
};
}
/** The problem-dismissal marker (ask-style discard, owner 2026-10-04):
* UI-ONLY — toLlmMessages filters it. The model's face arrives later, at
* the user's next input, as the synthesized conclude pair
* (concludedByDismissalPair below) — the agent sees the dismissal as a
* conclude call it "made" with outcome "dismissed", so the slot-freed
* state and the dismissal read through the one vocabulary it owns. */
export function newProblemDismissedMarker(problemCallId: string): LhMarkerMessage {
return {
id: newMessageId(),
role: "custom",
customType: "problem-dismissed",
content: "",
display: true,
details: { problemCallId },
timestamp: Date.now(),
};
}
/** The dismissal's deferred conclude (owner ruling 2026-10-04): the pair
* prepended to the user's NEXT input — a synthetic assistant message
* carrying a conclude call (outcome "dismissed") plus its ack result, so
* both the record and the model context read the problem as concluded
* by dismissal, slot freed. Pure; ids are deterministic off the problem
* call id (idempotent shape — the pending-dismissal gate guarantees the
* pair appends at most once per marker). */
export function concludedByDismissalPair(
problemCallId: string,
): [LhMessage & AssistantMessage, LhMessage & ToolResultMessage] {
const callId = `call_conc_${problemCallId}`;
const now = Date.now();
// The stamps are placeholders by design: history serialization reads
// ONLY the content blocks (verified in pi-ai's converters), and the
// app's money channel is the parallel turns log, never message
// objects — so "synthetic" never corrupts a wire view or a cost fold.
const syntheticStamps = {
api: "faux",
provider: "synthetic",
model: "synthetic-conclude",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
} as const;
return [
{
id: newMessageId(),
role: "assistant",
content: [{ type: "toolCall", id: callId, name: PRACTICE_CONCLUDE_TOOL, arguments: { outcome: "dismissed" } }],
timestamp: now,
...syntheticStamps,
},
{
id: newMessageId(),
role: "toolResult",
toolCallId: callId,
toolName: PRACTICE_CONCLUDE_TOOL,
content: [{ type: "text", text: CONCLUDE_ACK_TEXT }],
details: { outcome: "dismissed", problemCallId },
isError: false,
timestamp: now,
},
];
}
/** The active-problem fold (the conclude-before-next-problem gate): the
* NEWEST problem tool call with nothing concluding after it — either a
* conclude call or a dismissal marker deactivates. The ack result at pose
* time does NOT (the problem stays on screen through nudges; the dock's
* subject is the call, not a dangling result). */
export function activeProblemOf(
messages: readonly LhMessage[],
): { toolCallId: string; input: unknown } | null {
for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]!;
const role = (m as { role?: string }).role;
if (role === "custom") {
const marker = m as LhMarkerMessage;
if (marker.customType === "problem-dismissed") return null;
continue;
}
if (role === "assistant") {
const assistant = m as AssistantMessage & { id: string };
for (let bi = assistant.content.length - 1; bi >= 0; bi--) {
const block = assistant.content[bi]!;
if (block.type !== "toolCall") continue;
if (block.name === PRACTICE_CONCLUDE_TOOL) return null;
if (block.name !== PRACTICE_PROBLEM_TOOL) continue;
return { toolCallId: block.id, input: block.arguments };
}
continue;
}
}
return null;
}
/** Is a problem-dismissal marker waiting for its conclude synthesis? The
* text/continue appends read this: the pair rides the user's next input
* (input-events). Newest-marker-wins scan: the first dismissal marker or
* conclude call from the end decides. */
export function pendingProblemDismissalOf(messages: readonly LhMessage[]): string | null {
for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]!;
const role = (m as { role?: string }).role;
if (role === "custom") {
const marker = m as LhMarkerMessage;
if (marker.customType === "problem-dismissed") {
return (marker.details as { problemCallId?: unknown }).problemCallId as string;
}
continue;
}
if (role === "assistant") {
const assistant = m as AssistantMessage;
for (let bi = assistant.content.length - 1; bi >= 0; bi--) {
const block = assistant.content[bi]!;
if (block.type !== "toolCall" && block.type !== "text") continue;
if (block.type === "toolCall" && block.name === PRACTICE_CONCLUDE_TOOL) return null;
if (block.type === "toolCall" && block.name === PRACTICE_PROBLEM_TOOL) return null;
}
continue;
}
}
return null;
}
// ─── the one model-facing seam (the agent's convertToLlm) ────────────────────
/** The ids of every toolResult in the tail (for the dangling-call scan). */
@@ -206,7 +371,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
const role = (m as { role?: string }).role;
if (role === "custom") {
const marker = m as LhMarkerMessage;
if (marker.customType === "plan-block") continue; // UI-only
// UI-only markers: plan blocks, and problem dismissals (the
// synthesized conclude pair carries the model's face).
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
out.push({
role: "user",
content: [{ type: "text", text: marker.content }],