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:
@@ -8,22 +8,27 @@
|
||||
*/
|
||||
import type { UIMessage } from "ai";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { LhMessage } from "../native-messages.ts";
|
||||
import type { Phase3 } from "../phases.ts";
|
||||
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
|
||||
import type { EmissionStats, UnderstandingLevel } from "./graph.ts";
|
||||
|
||||
// ─── Chat records (the session store's on-disk format) ───────────────────────
|
||||
|
||||
/** One persisted session: data/chats/<id>.json. The stored messages are full
|
||||
* AI SDK UIMessages (parts included) — the canonical session record: a
|
||||
* pending quiz is a tool part in `input-available` state, which is exactly
|
||||
* what makes resume-mid-question free. */
|
||||
/** One persisted session: data/chats/<id>.json. Wave B (owner-approved
|
||||
* 2026-09-26): the stored messages are pi-NATIVE history (LhMessage — the
|
||||
* agent loop's own shapes, provider/model/usage/thinkingSignature stamped
|
||||
* by pi) — the canonical session record IS the agent state; replay is
|
||||
* `state.messages = record.messages` + continue(). The client-shaped view
|
||||
* is the one-way projection (core/projection.ts) — a pending quiz is an
|
||||
* unanswered question tool call, which is exactly what makes
|
||||
* resume-mid-question free. */
|
||||
export interface ChatRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: UIMessage[];
|
||||
messages: LhMessage[];
|
||||
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
|
||||
tree?: PlanTree;
|
||||
/** Append-only usage log (observability, 2026-09): one entry per model
|
||||
@@ -152,8 +157,8 @@ export interface TurnUsage {
|
||||
* both verdicts are real transcript events: accept sanctions teaching,
|
||||
* decline asks for revision. The part is the message TYPE (the "custom
|
||||
* message" scheme, 2026-09-12): the message carries no human-authored text,
|
||||
* the model-facing wording is owned by the adapter (record-adapter.ts
|
||||
* flattens it to VERDICT_TEXT below), and the UI renders the verdict
|
||||
* the model-facing wording is owned by toLlmMessages (native-messages.ts
|
||||
* flattens it to verdictText below), and the UI renders the verdict
|
||||
* marker — never a user bubble. A learner who also typed a note sends the
|
||||
* note as its own real user message first; the verdict message follows. */
|
||||
export interface PlanVerdictData {
|
||||
|
||||
+44
-73
@@ -6,8 +6,9 @@
|
||||
* master. Pure types + protocol docs + tiny pure helpers; type-only imports
|
||||
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
||||
*/
|
||||
import type { UIMessage } from "ai";
|
||||
import type { CallUsage } from "./chat.ts";
|
||||
import type { LhMessage, LhMarkerMessage } from "../native-messages.ts";
|
||||
import { newHandoffMessage } from "../native-messages.ts";
|
||||
|
||||
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
||||
|
||||
@@ -141,7 +142,7 @@ export function accumulateJobUsage(
|
||||
*
|
||||
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
|
||||
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
|
||||
* UIMessage carrying the result — a text part with what the MODEL needs
|
||||
* result-carrying native marker — the content is what the MODEL needs
|
||||
* (the research brief, the builder's notes), plus a data-subagent-artifact
|
||||
* part with what the UI renders (the full result: brief + sources /
|
||||
* SVG / demo HTML). Provider conversion drops data parts, so artifact
|
||||
@@ -245,6 +246,14 @@ export interface PdfResult {
|
||||
}
|
||||
|
||||
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
|
||||
//
|
||||
// Wave B (owner-approved 2026-09-26): steers are NATIVE record messages —
|
||||
// LhMarkerMessage customs (role "custom"), the custom-message taxonomy's
|
||||
// wake members. The model-facing text rides `content` (single-sourced
|
||||
// below); the UI payload (the artifact, the abort badge) rides `details`
|
||||
// and never reaches the model (toLlmMessages maps the marker to a user
|
||||
// message carrying `content` only). The deterministic id (`steer_<jobId>`)
|
||||
// IS the idempotency key — steerDelivered matches on it.
|
||||
|
||||
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
|
||||
export interface SubagentResultMeta {
|
||||
@@ -264,40 +273,14 @@ export interface SubagentArtifactData {
|
||||
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* The steer message: user-role on the wire (UIMessage roles are fixed), but
|
||||
* NEVER a learner interjection — metadata.subagentResult is the marker the
|
||||
* UI branches on, and the artifact rides the data part (stripped by provider
|
||||
* conversion, so the model sees only the text part).
|
||||
*/
|
||||
export interface SubagentSteerMessage extends UIMessage {
|
||||
role: "user";
|
||||
metadata: { subagentResult: SubagentResultMeta };
|
||||
parts: [
|
||||
{ type: "text"; text: string },
|
||||
{ type: "data-subagent-artifact"; data: SubagentArtifactData },
|
||||
];
|
||||
/** A result steer's marker details: the UI's meta + artifact in one
|
||||
* payload (the projection splits it into metadata + data part). */
|
||||
export interface SteerDetails extends SubagentResultMeta {
|
||||
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
||||
}
|
||||
|
||||
/** The abort steer: user-role like any steer, but NO artifact part —
|
||||
* there is no result to render, only the teacher-facing text. Shares the
|
||||
* result steer's deterministic id space (`steer_${jobId}`): steerDelivered
|
||||
* matches on metadata jobId, so an abort and a (racing) result steer
|
||||
* can never BOTH deliver — exactly one wakes the teacher. */
|
||||
export interface AbortSteerMessage extends UIMessage {
|
||||
role: "user";
|
||||
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
|
||||
parts: [
|
||||
{ type: "text"; text: string },
|
||||
{ type: "data-subagent-aborted"; data: SubagentResultMeta & { aborted: true } },
|
||||
];
|
||||
}
|
||||
|
||||
/** Anything the wake pipeline may queue and deliver. */
|
||||
export type SteerMessage =
|
||||
| SubagentSteerMessage
|
||||
| AbortSteerMessage
|
||||
| ProbeHandoffSteerMessage;
|
||||
/** Anything the wake pipeline may queue and deliver — a native marker. */
|
||||
export type SteerMessage = LhMarkerMessage;
|
||||
|
||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||
export function isSubagentFailure(
|
||||
@@ -354,35 +337,38 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe
|
||||
export function buildSubagentSteer(
|
||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||
result: ResearchResult | DemoResult | VisualResult | PdfResult,
|
||||
): SubagentSteerMessage {
|
||||
): SteerMessage {
|
||||
return {
|
||||
id: `steer_${job.id}`,
|
||||
role: "user",
|
||||
metadata: { subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId } },
|
||||
parts: [
|
||||
{ type: "text", text: steerText(job.kind, result) },
|
||||
{ type: "data-subagent-artifact", data: { kind: job.kind, result } },
|
||||
],
|
||||
role: "custom",
|
||||
customType: "subagent-steer",
|
||||
content: steerText(job.kind, result),
|
||||
display: true,
|
||||
details: {
|
||||
jobId: job.id,
|
||||
kind: job.kind,
|
||||
toolCallId: job.toolCallId,
|
||||
result,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the abort steer for an aborted job (pure — shared by the
|
||||
* abort path in src/server/jobs.ts and tests). Same id space as the result
|
||||
* steer; see AbortSteerMessage. */
|
||||
* steer (same id space — exactly one delivers). */
|
||||
export function buildAbortSteer(
|
||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||
): AbortSteerMessage {
|
||||
): SteerMessage {
|
||||
const meta = { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true } as const;
|
||||
return {
|
||||
id: `steer_${job.id}`,
|
||||
role: "user",
|
||||
metadata: { subagentResult: meta },
|
||||
parts: [
|
||||
{ type: "text", text: abortText(job.kind) },
|
||||
// The message-type marker (the custom-message scheme): the UI
|
||||
// renders the abort badge off this part, not off metadata.
|
||||
{ type: "data-subagent-aborted", data: meta },
|
||||
],
|
||||
role: "custom",
|
||||
customType: "subagent-abort",
|
||||
content: abortText(job.kind),
|
||||
display: true,
|
||||
details: meta,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -403,34 +389,19 @@ function abortText(kind: JobKind): string {
|
||||
// probe_done's execute queues this through the same wake pipeline; the
|
||||
// pump fires it once the prober's turn ends (gates + grace). It carries no
|
||||
// artifact — the assessment it references is durable in the transcript as
|
||||
// the probe_done tool part. Deterministic id per chat: exactly one
|
||||
// handoff, idempotent re-delivery.
|
||||
// the probe_done tool result. Deterministic id per chat: exactly one
|
||||
// handoff, idempotent re-delivery. The marker's content is HANDOFF_TEXT
|
||||
// (core/contracts/chat.ts — single source); the UI renders the quiet
|
||||
// system line off the customType. Nothing human-looking is persisted, ever.
|
||||
|
||||
export interface ProbeHandoffSteerMessage extends UIMessage {
|
||||
id: `steer_probe_${string}`;
|
||||
role: "user";
|
||||
metadata: { probeHandoff: { chatId: string } };
|
||||
/** The custom-message scheme (2026-09-12): NO text part — the wake is
|
||||
* scaffolding, not speech. The model-facing instruction is owned by the
|
||||
* adapter (record-adapter.ts flattens this part to HANDOFF_TEXT in
|
||||
* core/contracts/chat.ts); the UI renders the quiet system line off the
|
||||
* part. Nothing human-looking is persisted, ever. */
|
||||
parts: [{ type: "data-probe-handoff"; data: { chatId: string } }];
|
||||
}
|
||||
|
||||
export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage {
|
||||
return {
|
||||
id: `steer_probe_${chatId}`,
|
||||
role: "user",
|
||||
metadata: { probeHandoff: { chatId } },
|
||||
parts: [{ type: "data-probe-handoff", data: { chatId } }],
|
||||
};
|
||||
export function buildProbeHandoffSteer(chatId: string): SteerMessage {
|
||||
return newHandoffMessage(chatId);
|
||||
}
|
||||
|
||||
/** True when the transcript already carries this steer — idempotent
|
||||
* delivery for ANY steer kind: every steer id is deterministic per its
|
||||
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
|
||||
export function steerDelivered(messages: UIMessage[], steerId: string): boolean {
|
||||
export function steerDelivered(messages: LhMessage[], steerId: string): boolean {
|
||||
return messages.some((m) => m.id === steerId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user