Files
learnharness/src/server/pi/record-adapter.ts
T
amosblomqvist 1d45d77303 feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)
One shot per chat, fired at the first turn's onFinish: the session's
topic (its only typed message) is embedded as an EmbeddingGemma retrieval
query; the semantically closest graph propositions are selected under the
session's token budget (pure budget, no similarity threshold — the
decided doctrine) and delivered as a marker-only steer through the wake
pipeline, queuing behind pending questions like every sub-agent result.

- contracts: ChatSettings.graphContextTokens (default 1024, max 4096,
  step 128, 0 = off); GraphContextData + graphContextText; the
  graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>)
- core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill
  under budget, most-recent UnderstandingLevel fold, count cap 64,
  parse-don't-cast at the marker seam
- server: embedQuery (role-asymmetric query format, same warm singleton
  — vector-space parity by construction); the runner (startGraphContext-
  Injection one-shot gates; runGraphContextJob validates ledger parity);
  wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs
  re-deliver or contextRespawn); abortJob kills context jobs SILENTLY
  (nothing was promised in the lesson); turn-service trigger seam
- adapter: data-graph-context flattens to the canonical block (malformed
  parts have no model face)
- UI: the budget slider (home dock + /chat/new draft, the session-data
  strip); the quiet SystemLine "~537 · 10 propositions"; dock kind
  "Knowledge context"; model-picker PUT now spreads settings (a model
  pick can no longer clear the sibling override)

Live-smoked end to end on the real embedder + real GLM: seeded ledger
(10 propositions), session on "modal logic: possible worlds and the
necessity box" — job in the dock, queued behind the prober's question,
delivered on the answer, SystemLine in the transcript, wake turn reads
the block and continues the probe. Gates: tsc, lint, 459/459, build.
2026-09-15 07:54:50 +03:00

198 lines
7.4 KiB
TypeScript

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 { graphContextOfMessage } from "../../core/graph-context.ts";
import { graphContextText, 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;
// Graph context injection: the block text derives from the part's
// validated data (parse-don't-cast — a malformed part has no model
// face, and the message then never reaches the provider).
if (part.type === "data-graph-context") {
const data = graphContextOfMessage({ parts: message.parts });
return data ? graphContextText(data) : null;
}
}
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[];
}