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:
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user