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
+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 }],