Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* THE NATIVE MESSAGE RECORD (Wave B, owner-approved 2026-09-26): chat
|
||||
* records store pi's own message shapes — the exact history the agent
|
||||
* loop builds — instead of AI-SDK UIMessages. Provider, model, usage,
|
||||
* stopReason, and thinking signatures are stamped by pi itself; replay is
|
||||
* `state.messages = record.messages` + `continue()` — the record IS the
|
||||
* agent state.
|
||||
*
|
||||
* WHY pi MESSAGES LACK OUR `id`: pi's session format gives every ENTRY an
|
||||
* id, never the message. This app needs a per-message id for client
|
||||
* identity (React keys, append-only adoption, the continuation seam), so
|
||||
* every record message carries ONE extra field — `id` — alongside the
|
||||
* native shape. `toLlmMessages` strips it at the wire: the provider never
|
||||
* 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,
|
||||
* 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,
|
||||
* content, display, details}). ONE seam — toLlmMessages, wired as the
|
||||
* agent's convertToLlm — owns every model-facing decision: markers
|
||||
* flatten to their canonical user text (single-sourced in
|
||||
* core/contracts), plan blocks are UI-only and filtered, and dangling
|
||||
* tool calls (an aborted turn) get the interrupted-result synthesis the
|
||||
* provider protocol demands.
|
||||
*
|
||||
* Answers (fork 1, owner 2026-09-26): the learner's answer IS the tool
|
||||
* call's ToolResultMessage — appended natively at answer time. The
|
||||
* synthetic "question posed" placeholder is an in-memory loop device
|
||||
* (Wave A's terminate) and is never persisted: the record ends at the
|
||||
* unanswered call in the pose→answer window, where no turn can run (the
|
||||
* question gate holds steers; a POST would 409).
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
/** The hard-invariant wording for an aborted turn's unanswered calls —
|
||||
* carried over verbatim from the pre-native era so the model's view
|
||||
* never drifted. */
|
||||
export const INTERRUPTED_TEXT =
|
||||
"The question was interrupted — the learner never answered; continue without holding a result for it.";
|
||||
|
||||
// ─── The record's message type ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The app's custom message (declaration-merged into pi-agent-core's
|
||||
* CustomAgentMessages below). Shaped like pi's own CustomMessage —
|
||||
* `content` is the MODEL-facing text (single-sourced in core/contracts,
|
||||
* never fake human speech), `details` is the UI payload (never
|
||||
* model-visible), `display` marks messages the UI renders (our projection
|
||||
* keys rendering on customType; the flag documents intent).
|
||||
*/
|
||||
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";
|
||||
/** The model-facing text ("" for plan blocks — UI-only). */
|
||||
content: string;
|
||||
display: boolean;
|
||||
/** The UI payload — per-customType, never model-visible. */
|
||||
details: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
declare module "@earendil-works/pi-agent-core" {
|
||||
interface CustomAgentMessages {
|
||||
lhMarker: LhMarkerMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/** A record message: a native pi message (or our marker) plus the record id. */
|
||||
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string };
|
||||
|
||||
/** A record message list is assignable to the agent's context — that IS
|
||||
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
|
||||
export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[] {
|
||||
return messages as unknown as AgentMessage[];
|
||||
}
|
||||
|
||||
// ─── id minting ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** The app-wide message id shape (same as the UIMessage-era mint). */
|
||||
export function newMessageId(): string {
|
||||
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// ─── constructors (the input-event → native append mapping lives in
|
||||
// core/input-events.ts; steers in core/contracts/jobs.ts) ─────────────────
|
||||
|
||||
/** A learner-authored user message (text input, verdict note). */
|
||||
export function newUserMessage(text: string): LhMessage & UserMessage {
|
||||
return {
|
||||
id: newMessageId(),
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The learner's answer — the question call's tool result (fork 1).
|
||||
* `answer` is the structured QuestionAnswerData the client graded; the
|
||||
* MODEL sees its JSON encoding (the pre-native wire shape),
|
||||
* the UI sees the structured payload via `details`. */
|
||||
export function newAnswerResult(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
answer: unknown,
|
||||
): LhMessage & ToolResultMessage {
|
||||
return {
|
||||
id: newMessageId(),
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: [{ type: "text", text: JSON.stringify(answer) }],
|
||||
details: answer,
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return {
|
||||
id: `steer_probe_${chatId}`,
|
||||
role: "custom",
|
||||
customType: "probe-handoff",
|
||||
content: HANDOFF_TEXT,
|
||||
display: true,
|
||||
details: { chatId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The plan presentation block: pure UI history (a frozen snapshot), no
|
||||
* model face — toLlmMessages filters it. `planCallId` keys idempotent
|
||||
* emission (one block per successful plan_init call). */
|
||||
export function newPlanBlockMessage(
|
||||
planCallId: string,
|
||||
block: unknown,
|
||||
): LhMarkerMessage {
|
||||
return {
|
||||
id: `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||
role: "custom",
|
||||
customType: "plan-block",
|
||||
content: "",
|
||||
display: true,
|
||||
details: { planCallId, block },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── the one model-facing seam (the agent's convertToLlm) ────────────────────
|
||||
|
||||
/** The ids of every toolResult in the tail (for the dangling-call scan). */
|
||||
function resultIdsFrom(messages: readonly AgentMessage[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) {
|
||||
if ((m as { role?: string }).role === "toolResult") {
|
||||
ids.add((m as ToolResultMessage).toolCallId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentMessage[] → the provider wire (the agent's convertToLlm — required
|
||||
* by pi-agent-core's loop config, the ONE place model-facing decisions
|
||||
* live):
|
||||
*
|
||||
* - user / assistant / toolResult pass through VERBATIM — signatures,
|
||||
* usage, provider stamps all native — minus our record-`id` field.
|
||||
* - markers flatten to a user message carrying their content (the
|
||||
* canonical text single-sourced in core/contracts); plan blocks are
|
||||
* UI-only and filtered.
|
||||
* - dangling tool calls (an aborted turn's unanswered questions) get the
|
||||
* synthesized interrupted result — the provider protocol's hard
|
||||
* invariant: a tool call must be followed by its result.
|
||||
*
|
||||
* Contract (pi-agent-core): never throws — an unknown shape passes
|
||||
* through untouched rather than killing the loop.
|
||||
*/
|
||||
export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
const out: Message[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const m = messages[i]!;
|
||||
const role = (m as { role?: string }).role;
|
||||
if (role === "custom") {
|
||||
const marker = m as LhMarkerMessage;
|
||||
if (marker.customType === "plan-block") continue; // UI-only
|
||||
out.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: marker.content }],
|
||||
timestamp: marker.timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Strip the record id: the wire carries the native shape only.
|
||||
const { id: _id, ...native } = m as LhMessage;
|
||||
out.push(native as Message);
|
||||
// Dangling-call synthesis: an assistant message whose toolCalls have
|
||||
// no results in the remaining tail (abort mid-question) — the
|
||||
// provider rejects unanswered calls outright.
|
||||
if (role === "assistant") {
|
||||
const assistant = m as AssistantMessage;
|
||||
const calls = assistant.content.filter((b) => b.type === "toolCall");
|
||||
if (calls.length === 0) continue;
|
||||
const answered = resultIdsFrom(messages.slice(i + 1));
|
||||
for (const call of calls) {
|
||||
if (answered.has(call.id)) continue;
|
||||
out.push({
|
||||
role: "toolResult",
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
content: [{ type: "text", text: INTERRUPTED_TEXT }],
|
||||
isError: false,
|
||||
timestamp: assistant.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── native scans (the UIMessage-era pending/phase seams, natively) ──────────
|
||||
|
||||
/** The pending-question gate (findPendingToolCall's native twin): the
|
||||
* NEWEST assistant message's FIRST question-tool call with no result in
|
||||
* the tail — one question at a time, the dock's subject. */
|
||||
export function danglingQuestionOf(
|
||||
messages: readonly LhMessage[],
|
||||
): { toolCallId: string; tool: string; input: unknown } | null {
|
||||
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]!;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage & { id: string };
|
||||
const answered = resultIdsFrom(messages.slice(mi + 1));
|
||||
for (const block of assistant.content) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue;
|
||||
if (answered.has(block.id)) continue;
|
||||
return { toolCallId: block.id, tool: block.name, input: block.arguments };
|
||||
}
|
||||
return null; // the newest assistant message's questions are all answered
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The probe→teach construction seam (chat-handler): a probe_done call in
|
||||
* the transcript — any state — means the probe is concluded: teach. */
|
||||
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
|
||||
for (const m of messages) {
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage;
|
||||
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every toolResult id in the record — answer-event validation (the call
|
||||
* must be a DANGLING question call, not an already-answered one). */
|
||||
export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
|
||||
return resultIdsFrom(messages);
|
||||
}
|
||||
Reference in New Issue
Block a user