Files
learnharness/src/server/turn-service.ts
T
amosblomqvist f3003d357a Wave D: native AgentEvent wire + client session rewrite (SDK purged)
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).

Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).

Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.

Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).

Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
2026-09-26 01:52:47 +03:00

131 lines
5.7 KiB
TypeScript

/**
* The chat-turn services — one named, error-contained seam per turn
* responsibility, extracted from the chat-handler so it composes instead of
* implementing. Each takes its dependencies explicitly (storage ports, the
* job runner) — no module here imports an adapter.
*
* persistTurn — the canonical state write (Wave B: native history;
* id stamping, placeholder filtering, plan-block
* emission, abort-partial persistence)
* triggerExtraction — the graph write channel: fires the extraction
* pipeline (the sole graph writer) once a chat
* crosses the token-budget threshold.
*
* Logs are the minimal-observability baseline: one line per failure, tagged
* [chat <id>] — structured logging lands behind these seams later.
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMessage } from "../core/native-messages.ts";
import { newMessageId } from "../core/native-messages.ts";
import { QUESTION_TOOLS } from "../core/pending.ts";
import { planBlocksFor } from "../core/plan-blocks.ts";
import { coverageOf } from "../core/coverage.ts";
import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
/**
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
* turn's own messages — the agent loop's native history, provider/model/
* usage/thinkingSignature stamped by pi itself. Persists even when aborted
* — the partial transcript (including a pending question's unanswered
* call) is what makes resume work.
*
* - Id stamping: pi messages carry no id; the record's identity needs one
* per message (client keys, append-only adoption, the continuation
* seam). Minted here, at the single canonical write.
* - Placeholder filtering: a cleanly-posed question's synthetic
* "question posed" result is an in-memory loop device (Wave A's
* terminate) — NEVER persisted (fork 1: the record ends at the
* unanswered call; the learner's answer arrives as the real tool
* result). Errored question calls DO persist — the model's retry
* context.
* - Plan blocks: one frozen snapshot per successful plan_init call,
* idempotent (persist replays are safe).
*
* Returns the stamped new messages (usage provenance reads them).
*/
export function persistTurn(
chatStore: ChatStore,
chatId: string,
baseCount: number,
turnMessages: readonly AgentMessage[],
isAborted: boolean,
): LhMessage[] {
try {
const record = chatStore.getChat(chatId);
if (!record) return [];
const stamped: LhMessage[] = [];
for (const m of turnMessages.slice(baseCount)) {
const message = m as LhMessage;
// The placeholder question result: loop device, never a record fact.
if (
(message as { role?: string }).role === "toolResult" &&
(QUESTION_TOOLS as readonly string[]).includes((message as ToolResultMessage).toolName) &&
!(message as ToolResultMessage).isError
) {
continue;
}
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
}
const tree = record.tree;
const blocks = planBlocksFor(record.messages, stamped, tree, [
...record.messages,
...stamped,
]);
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
return stamped;
} catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
return [];
}
}
/**
* The graph write channel (vision §1 construction): the extraction pipeline
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
* chat's UNCOVERED transcript tokens reach the chunk budget
* (vision-extraction-coverage.md, 2026-09-15). Coverage reads through the
* projection (the record is native history; the extraction view stays
* client-shaped until Wave D). Fire-and-forget — never blocks the turn;
* failures are logged by the pipeline and leave coverage untouched, so the
* next trigger (or the manual extract button) re-runs what's left.
*
* Deliberately isolated in this one seam — the pipeline is
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
* knowledge (the pipeline itself is tested directly with mock models).
*/
export function triggerExtraction(
deps: { chatStore: ChatStore; graphStore: GraphStore },
extraction: ExtractionPipeline,
chatId: string,
chunkTokens: number,
): void {
if (process.env.NODE_ENV === "test") return;
try {
const record = deps.chatStore.getChat(chatId);
if (!record) return;
const covered = coverageOf(record.messages, deps.graphStore.readCoverage(), chatId);
if (covered.uncoveredTokens < chunkTokens) return;
void extraction.runExtraction(chatId).catch((err) => {
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
// 2026-09-25): an unconfigured OR keyless write channel SKIPS (the
// coverage ledger keeps the messages uncovered — the next trigger
// retries once slots/keys are set); only real failures log.
if (err instanceof ModelsNotConfiguredError) {
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (missing: ${err.envelope.missing.join(", ")})`);
return;
}
if (err instanceof ProviderKeyMissingError) {
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (provider: ${err.envelope.provider})`);
return;
}
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
});
} catch (err) {
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
}
}