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