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:
amosblomqvist
2026-09-26 00:37:46 +03:00
parent b87a6925af
commit 7ab47d4fcb
36 changed files with 2410 additions and 1447 deletions
-190
View File
@@ -1,190 +0,0 @@
import type { UIMessage } from "ai";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
/**
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The
* persisted UIMessage record IS the source of truth; every turn builds a
* fresh agent from it (the stateless per-turn decision). This replaces the
* SDK's convertToModelMessages with an owned, explicit mapping:
*
* - user text parts → UserMessage text content.
* - SYSTEM DATA PARTS (the custom-message scheme, 2026-09-12): the wake and
* verdict messages carry ONLY a marker part — no human text exists to
* drop, so the ADAPTER flattens them: data-verdict → verdictText(),
* data-probe-handoff → HANDOFF_TEXT (core/contracts/chat.ts owns both —
* single source, so the model-facing wording can't drift from the seam).
* Other data parts (artifacts, plan blocks) are dropped as before: they
* are UI history; the steer briefs ride their text twin (payload, not
* boilerplate — those texts are content the sub-agent produced).
* - assistant text/reasoning/tool parts → one AssistantMessage whose
* content blocks mirror the part order; tool parts become ToolCall
* blocks (output-error parts keep their rawInput).
* - answered tool parts (output-available) → ToolResultMessage after the
* assistant message, JSON-encoded like the model saw it live.
* - errored tool parts (output-error) → an isError ToolResultMessage
* carrying the validation message (the model's retry context).
* - DANGLING calls (input-available, never answered — an aborted turn)
* → a synthesized interrupted ToolResultMessage. THE HARD INVARIANT:
* an assistant message with tool calls must be followed by tool
* results, or providers reject the request outright.
*/
const INTERRUPTED = "The question was interrupted — the learner never answered; continue without holding a result for it.";
function textOf(part: { text?: string }): string {
return typeof part.text === "string" ? part.text : "";
}
/** Encode a tool output the way the model saw it live (string verbatim, object JSON). */
function toolResultText(output: unknown): string {
return typeof output === "string" ? output : JSON.stringify(output);
}
function zeroUsage() {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
/** The model-facing text of a marker-only user message, or null when the
* message has no model face at all (UI-only data parts). */
function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string | null {
for (const part of message.parts) {
if (part.type === "data-verdict") {
return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true);
}
if (part.type === "data-probe-handoff") return HANDOFF_TEXT;
}
return null;
}
export function recordToPiMessages(messages: UIMessage[]): Message[] {
const out: AgentMessage[] = [];
for (const message of messages) {
if (message.role === "user") {
const texts = message.parts
.filter((p) => p.type === "text")
.map((p) => textOf(p as { text?: string }))
.filter((t) => t.length > 0);
// The custom-message scheme: a marker-only message (verdict,
// handoff) flattens to its canonical model-facing text — the
// wording lives in the adapter's reach (core contracts), never
// authored as fake human speech in the transcript.
if (texts.length === 0) {
const flat = systemTextOf(message);
if (flat === null) continue; // pure UI data part, no model face
texts.push(flat);
}
out.push({
role: "user",
content: texts.map((text) => ({ type: "text" as const, text })),
timestamp: Date.now(),
});
} else if (message.role === "assistant") {
const content: AssistantMessage["content"] = [];
const results: ToolResultMessage[] = [];
let answered = false;
for (const part of message.parts) {
if (part.type === "text") {
const text = textOf(part);
if (text) content.push({ type: "text", text });
} else if (part.type === "reasoning") {
const text = textOf(part);
if (text) content.push({ type: "thinking", thinking: text });
} else if (part.type === "step-start") {
// presentation-only; the pi loop re-derives turn boundaries
} else if (part.type.startsWith("tool-")) {
const tool = part.type.slice("tool-".length);
const p = part as unknown as {
toolCallId: string;
state: string;
input?: unknown;
rawInput?: unknown;
output?: unknown;
errorText?: string;
};
// The call block (any state — the model authored it).
if (p.state === "input-available" || p.state === "input-streaming" || p.state === "output-available") {
content.push({
type: "toolCall",
id: p.toolCallId,
name: tool,
arguments: (p.input ?? {}) as Record<string, unknown>,
});
} else if (p.state === "output-error") {
content.push({
type: "toolCall",
id: p.toolCallId,
name: tool,
arguments: (p.rawInput ?? p.input ?? {}) as Record<string, unknown>,
});
}
// The result, if any.
if (p.state === "output-available") {
results.push({
role: "toolResult",
toolCallId: p.toolCallId,
toolName: tool,
content: [{ type: "text", text: toolResultText(p.output) }],
isError: false,
timestamp: Date.now(),
});
answered = true;
} else if (p.state === "output-error") {
results.push({
role: "toolResult",
toolCallId: p.toolCallId,
toolName: tool,
content: [{ type: "text", text: p.errorText ?? "The tool call failed validation." }],
isError: true,
timestamp: Date.now(),
});
answered = true;
}
}
// data-* parts (plan blocks, artifacts, markers) never reach the model.
}
if (content.length === 0) continue; // presentation-only message
out.push({
role: "assistant",
content,
api: "openai-completions",
provider: "openrouter",
model: "replayed",
usage: zeroUsage(),
stopReason: content[content.length - 1]!.type === "toolCall" ? "toolUse" : "stop",
timestamp: Date.now(),
});
// Dangling calls: synthesize the interrupted result so providers
// never see an unanswered tool call (the hard invariant).
if (!answered) {
for (const block of content) {
if (block.type === "toolCall" && !results.some((r) => r.toolCallId === block.id)) {
results.push({
role: "toolResult",
toolCallId: block.id,
toolName: block.name,
content: [{ type: "text", text: INTERRUPTED }],
isError: false,
timestamp: Date.now(),
});
}
}
}
out.push(...results);
}
// role "system" never appears in records (system prompt is composed separately).
}
return out as Message[];
}
+33 -18
View File
@@ -1,6 +1,8 @@
import type { Agent, AgentEvent } from "@earendil-works/pi-agent-core";
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
import type { UIMessage } from "ai";
import { QUESTION_TOOLS } from "../../core/pending.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { projectNative } from "../../core/projection.ts";
/**
* The UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and
@@ -23,9 +25,10 @@ import { QUESTION_TOOLS } from "../../core/pending.ts";
* tool-input-error instead and NEVER becomes input-available, so a
* malformed question can never mount the dock (the live modal-realism
* crash: GLM double-encoded `options` as a JSON string).
* - The persisted message is assembled from the SAME parts the client
* assembles (the processUIMessageStream shapes), so every downstream
* consumer (phases, plan blocks, pending, extraction) is unchanged.
* - The WIRE assembly (below) is the client view only (Wave B): the
* record write is the agent's own native final state — the projection
* (core/projection.ts) reproduces the client's part shapes for every
* consumer (phases, plan blocks, pending, extraction) until Wave D.
*
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
* server-side background object decoupled from any single HTTP response —
@@ -42,12 +45,17 @@ type Chunk = Record<string, unknown> & { type: string };
export interface PiTurnSpec {
/** The per-turn agent (stateless engine — built from the record). */
agent: Agent;
/** The outgoing request messages (write-ahead persisted already). */
uiMessages: UIMessage[];
/** The turn's base: the native record + this turn's appends
* (write-ahead persisted already). Wave B: the agent's context and
* the record share one shape; onFinish receives the full agent state
* and the caller persists the slice past base.length. */
base: LhMessage[];
/** The handler's error-unmasking policy (validation messages stay readable). */
onError?: (error: unknown) => string;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn. */
onFinish: (messages: UIMessage[], isAborted: boolean) => void;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
* Receives the agent's FULL final state (native messages) — the turn's
* new messages are the slice past base.length. */
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
}
/** One view onto a running turn: push() replays/streams a chunk, done()
@@ -117,15 +125,22 @@ interface ToolPart {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
const { agent, uiMessages, onFinish } = spec;
const { agent, base, onFinish } = spec;
// The streaming target: continuation (same id, parts appended to a copy
// of the existing message) or a fresh assistant message.
const last = uiMessages[uiMessages.length - 1];
const continuing = last?.role === "assistant";
// of the existing message) or a fresh assistant message. The continuation
// rule is the CLIENT's: it keys on the last UIMessage's id — read through
// the projection (the record is native; a turn after an answer continues
// the group that carries the answered quiz, which is the projection's
// last message).
const projected = projectNative(base);
const last = projected[projected.length - 1] as
| { id?: string; role?: string; parts?: UIMessage["parts"] }
| undefined;
const continuing = last?.role === "assistant" && typeof last.id === "string";
const messageId =
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
const baseParts: UIMessage["parts"] = continuing ? [...last.parts] : [];
const baseParts: UIMessage["parts"] = continuing && last.parts ? [...last.parts] : [];
const parts: UIMessage["parts"] = [...baseParts];
// Stable, collision-free part ids across continuation runs (reasoning
@@ -174,11 +189,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
send({ type: "start", messageId });
const settleWith = () => {
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
// The SDK's onFinish shape: the input copy stays, the extended
// response copy appends (persistTurn's withMessageIds dedupe
// keeps the last occurrence).
onFinish([...uiMessages, assembled], sawAbort);
// Wave B: the record write is the agent's OWN final state (native
// history — signatures, usage, provider stamps all real); the wire
// assembly above served the client view only. Copy the array: the
// agent may outlive this call's read.
onFinish([...agent.state.messages], sawAbort);
};
const handle = (event: AgentEvent) => {