291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/**
|
||||
|
|
* THE COMPACTION SERVICE (owner round 14, 2026-10-05): the trigger
|
|||
|
|
* plumbing around core/compaction's pure cuts and markers.
|
|||
|
|
*
|
|||
|
|
* TOPOLOGY (why this is simple): question and problem poses TERMINATE
|
|||
|
|
* their runs, so the "user is thinking" pause is always a BETWEEN-RUNS
|
|||
|
|
* window. Compaction therefore never touches a live run:
|
|||
|
|
*
|
|||
|
|
* - SETTLE KICK — a turn settles (onFinish, after persist) and the
|
|||
|
|
* projected context is over threshold: the summary starts NOW, in
|
|||
|
|
* the background, while the learner reads/answers — usually done
|
|||
|
|
* long before the next input arrives.
|
|||
|
|
* - TURN-START GATE — every turn (client POST or wake) awaits any
|
|||
|
|
* in-flight compaction for its chat BEFORE appends or beginTurn;
|
|||
|
|
* if the record is STILL over threshold (cold sessions, kicks that
|
|||
|
|
* lost their engine), compaction runs synchronously inside the
|
|||
|
|
* request. Structurally gated: nothing model-facing for the chat
|
|||
|
|
* interleaves with a running compaction (owner ruling — minimal
|
|||
|
|
* surface for anything to go wrong).
|
|||
|
|
* - OVERFLOW RECOVERY — a run that dies on context length needs no
|
|||
|
|
* special path: the next turn's gate compacts and the fresh run
|
|||
|
|
* starts small.
|
|||
|
|
*
|
|||
|
|
* The summary is written by the session's OWN conversation model (owner
|
|||
|
|
* ruling) via the zero-tool one-shot sub-agent seam; its usage lands in
|
|||
|
|
* the chat's append-only turns log (the money channel) keyed by the
|
|||
|
|
* marker's id.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|||
|
|
import type { Usage } from "@earendil-works/pi-ai";
|
|||
|
|
import {
|
|||
|
|
activeCompactionOf,
|
|||
|
|
buildCompactionPrompt,
|
|||
|
|
compactionCutIndex,
|
|||
|
|
COMPACTION_RESERVE_TOKENS,
|
|||
|
|
estimateTokens,
|
|||
|
|
newCompactionMarker,
|
|||
|
|
planTreeToText,
|
|||
|
|
serializeForSummary,
|
|||
|
|
toLlmMessages,
|
|||
|
|
type CompactionDetails,
|
|||
|
|
type LhMessage,
|
|||
|
|
type LhMarkerMessage,
|
|||
|
|
} from "../core/index.ts";
|
|||
|
|
import { emit } from "../core/events.ts";
|
|||
|
|
import type { ChatStore } from "../core/ports.ts";
|
|||
|
|
import type { PiEngine } from "../agent/models.ts";
|
|||
|
|
import { runSubagent } from "../agent/subagents/pi-run.ts";
|
|||
|
|
|
|||
|
|
/** The summarizer's system prompt — server-owned operational framing,
|
|||
|
|
* not model-facing doctrine (buildCompactionPrompt carries the shape). */
|
|||
|
|
const SUMMARIZER_SYSTEM_PROMPT =
|
|||
|
|
"You are the session-compaction summarizer for a tutoring agent. You receive the transcript span to summarize and write exactly the summary the instructions demand — nothing else, no preamble, no meta-commentary.";
|
|||
|
|
|
|||
|
|
/** The one-shot summary call: zero tools, one turn, text out. The engine
|
|||
|
|
* is the chat's own conversation engine (owner ruling: the session's
|
|||
|
|
* model writes its own summary). Injectable for tests. */
|
|||
|
|
export type Summarizer = (prompt: string, engine: PiEngine) => Promise<{ summary: string; usage: Usage }>;
|
|||
|
|
|
|||
|
|
/** The production summarizer over the sub-agent seam. */
|
|||
|
|
async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ summary: string; usage: Usage }> {
|
|||
|
|
let usage: Usage = {
|
|||
|
|
input: 0,
|
|||
|
|
output: 0,
|
|||
|
|
cacheRead: 0,
|
|||
|
|
cacheWrite: 0,
|
|||
|
|
totalTokens: 0,
|
|||
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|||
|
|
};
|
|||
|
|
const { text } = await runSubagent([], {
|
|||
|
|
// The engine override is always present (the chat's own conversation
|
|||
|
|
// engine — owner ruling), so the role label never resolves an
|
|||
|
|
// engine; "researcher" is the least-wrong existing ModelRole label
|
|||
|
|
// for a one-shot text job (the cast documents the dead path).
|
|||
|
|
role: "researcher" as never,
|
|||
|
|
prompt,
|
|||
|
|
engine,
|
|||
|
|
systemPrompt: SUMMARIZER_SYSTEM_PROMPT,
|
|||
|
|
maxTurns: 1,
|
|||
|
|
onUsage: (u) => {
|
|||
|
|
usage = u;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
return { summary: text, usage };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Everything the service needs from its host (chat-handler wires the
|
|||
|
|
* production versions; tests inject). */
|
|||
|
|
export interface CompactionDeps {
|
|||
|
|
chatStore: ChatStore;
|
|||
|
|
/** The chat's conversation engine — also the summarizer's. */
|
|||
|
|
engine: PiEngine;
|
|||
|
|
/** provider:id of the engine (usage + marker provenance). */
|
|||
|
|
modelId: string;
|
|||
|
|
/** The session kind (summarizer wording: tutor vs practice). */
|
|||
|
|
kind: "teach" | "practice" | "probe";
|
|||
|
|
/** The effective trigger threshold, RESOLVED by the caller:
|
|||
|
|
* min(settings threshold, contextWindow − reserve) — the owner's
|
|||
|
|
* effective-trigger ruling. The catalog window lookup is the
|
|||
|
|
* caller's (chat-handler has the model id at hand). */
|
|||
|
|
thresholdTokens: number;
|
|||
|
|
/** The composed system prompt's token estimate (chars/4) — the
|
|||
|
|
* projection the provider actually receives starts with it. */
|
|||
|
|
systemPromptTokens: number;
|
|||
|
|
/** Injectable summarizer (tests); default = the sub-agent one-shot. */
|
|||
|
|
summarize?: Summarizer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── the registry (module-global on globalThis, jobs.ts's pattern) ─────────
|
|||
|
|
|
|||
|
|
interface CompactionState {
|
|||
|
|
inFlight: Map<string, Promise<void>>;
|
|||
|
|
}
|
|||
|
|
const g = globalThis as typeof globalThis & { __lhCompaction?: CompactionState };
|
|||
|
|
function state(): CompactionState {
|
|||
|
|
return (g.__lhCompaction ??= { inFlight: new Map() });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Is a compaction running for this chat? (The client status event's
|
|||
|
|
* server-side twin — tests and the SSE emitter read it.) */
|
|||
|
|
export function compactionInFlight(chatId: string): boolean {
|
|||
|
|
return state().inFlight.has(chatId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Await any in-flight compaction for the chat — the structural gate
|
|||
|
|
* every model interaction for the chat passes through. */
|
|||
|
|
export async function awaitCompaction(chatId: string): Promise<void> {
|
|||
|
|
const running = state().inFlight.get(chatId);
|
|||
|
|
if (running) await running;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── the effective threshold (owner ruling: min(setting, window − reserve)) ─
|
|||
|
|
|
|||
|
|
/** Resolve the effective trigger. Unknown window → the setting alone
|
|||
|
|
* (honest: no fake window — the catalog-index precedent). */
|
|||
|
|
export function effectiveThresholdTokens(settingTokens: number | undefined, contextWindowTokens: number | undefined): number {
|
|||
|
|
const setting = settingTokens ?? 150_000;
|
|||
|
|
if (contextWindowTokens === undefined) return setting;
|
|||
|
|
return Math.min(setting, Math.max(contextWindowTokens - COMPACTION_RESERVE_TOKENS, 1));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── the estimate over the ACTIVE projection ────────────────────────────────
|
|||
|
|
|
|||
|
|
/** The projected context estimate: what the next LLM call would carry —
|
|||
|
|
* toLlmMessages output (cut honored) + the system prompt. chars/4. */
|
|||
|
|
export function projectedContextTokens(messages: readonly LhMessage[], systemPromptTokens: number): number {
|
|||
|
|
return estimateTokens(toLlmMessages(messages as readonly AgentMessage[]) as unknown as readonly LhMessage[]) + systemPromptTokens;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── the compaction run ─────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/** Run one compaction for the chat (the registry mutex is HELD by the
|
|||
|
|
* caller — ensureCompacted). Reads the record fresh, cuts, summarizes,
|
|||
|
|
* appends the marker, records usage, notifies. Never throws upward
|
|||
|
|
* past the registry cleanup. */
|
|||
|
|
async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
|||
|
|
const record = deps.chatStore.getChat(chatId);
|
|||
|
|
if (!record) return;
|
|||
|
|
const messages = record.messages;
|
|||
|
|
const tokensBefore = projectedContextTokens(messages, deps.systemPromptTokens);
|
|||
|
|
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
|
|||
|
|
|
|||
|
|
const cut = compactionCutIndex(messages);
|
|||
|
|
if (cut >= messages.length) return; // nothing to drop (defensive)
|
|||
|
|
|
|||
|
|
// The previous compaction's kept boundary (chained compactions
|
|||
|
|
// summarize the old summary + what rode after it).
|
|||
|
|
const previous = activeCompactionOf(messages);
|
|||
|
|
const prevKept = previous ? messages.findIndex((m) => m.id === previous.details.firstKeptMessageId) : -1;
|
|||
|
|
// ANTI-CHURN GUARD: when the cut cannot advance past the previous
|
|||
|
|
// kept boundary, the span is empty (or repeats it) — the kept tail
|
|||
|
|
// alone carries the projection and re-summarizing the same span
|
|||
|
|
// cannot shrink it. Reachable only when the threshold sits below
|
|||
|
|
// kept-tail + summary (a hand-edited record below the settings
|
|||
|
|
// floor); skipping keeps the loop honest instead of churning.
|
|||
|
|
if (previous && prevKept !== -1 && cut <= prevKept) {
|
|||
|
|
console.info(
|
|||
|
|
`[chat ${chatId}] compaction skipped — the kept tail alone exceeds the threshold (raise it or shrink the tail)`,
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The span: from the PREVIOUS kept boundary through the cut.
|
|||
|
|
let spanStart = 0;
|
|||
|
|
let priorSummary: string | null = null;
|
|||
|
|
if (previous && prevKept > 0) {
|
|||
|
|
spanStart = prevKept;
|
|||
|
|
priorSummary = previous.content;
|
|||
|
|
}
|
|||
|
|
const spanText = serializeForSummary(messages.slice(spanStart, cut));
|
|||
|
|
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
|
|||
|
|
|
|||
|
|
const prompt = buildCompactionPrompt({
|
|||
|
|
spanText: fullSpan,
|
|||
|
|
planTreeText: planTreeToText(record.tree, messages),
|
|||
|
|
kind: deps.kind,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
emit("compaction-changed", { chatId, running: true });
|
|||
|
|
try {
|
|||
|
|
const summarize = deps.summarize ?? subagentSummarizer;
|
|||
|
|
const { summary, usage } = await summarize(prompt, deps.engine);
|
|||
|
|
const trimmed = summary.trim();
|
|||
|
|
if (!trimmed) return; // an empty summary is a refusal artifact — leave the record honest
|
|||
|
|
|
|||
|
|
const firstKept = messages[cut]!;
|
|||
|
|
const details: CompactionDetails = {
|
|||
|
|
firstKeptMessageId: firstKept.id,
|
|||
|
|
tokensBefore,
|
|||
|
|
model: deps.modelId,
|
|||
|
|
at: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
const marker = newCompactionMarker(trimmed, details);
|
|||
|
|
deps.chatStore.saveMessages(chatId, [...messages, marker]);
|
|||
|
|
// The money channel: the summary is a real model call on the
|
|||
|
|
// session's own engine — its usage keys to the marker (provenance:
|
|||
|
|
// the transcript shows the compaction divider at the marker).
|
|||
|
|
deps.chatStore.appendTurnUsage(chatId, {
|
|||
|
|
id: crypto.randomUUID(),
|
|||
|
|
messageId: marker.id,
|
|||
|
|
at: details.at,
|
|||
|
|
model: deps.modelId,
|
|||
|
|
steps: [
|
|||
|
|
{
|
|||
|
|
inputTokens: usage.input,
|
|||
|
|
outputTokens: usage.output,
|
|||
|
|
cacheReadTokens: usage.cacheRead,
|
|||
|
|
cacheWriteTokens: usage.cacheWrite,
|
|||
|
|
reasoningTokens: usage.reasoning ?? 0,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
// Notify-then-fetch: the record gained the marker (the client's
|
|||
|
|
// dividers appear) and the status event ends the send gate.
|
|||
|
|
emit("record-changed", { chatId });
|
|||
|
|
} catch (err) {
|
|||
|
|
// A failed compaction leaves the record untouched — the next
|
|||
|
|
// turn-start gate retries (the same self-healing law as the
|
|||
|
|
// extraction trigger).
|
|||
|
|
console.error(`[chat ${chatId}] compaction failed:`, err);
|
|||
|
|
} finally {
|
|||
|
|
emit("compaction-changed", { chatId, running: false });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The gate + trigger in one: await any in-flight compaction, then — if
|
|||
|
|
* the record is STILL over threshold — run one synchronously (the
|
|||
|
|
* caller's request waits; the owner's structural-gate ruling). Idempotent
|
|||
|
|
* under concurrency through the registry mutex.
|
|||
|
|
*/
|
|||
|
|
export async function ensureCompacted(deps: CompactionDeps, chatId: string): Promise<void> {
|
|||
|
|
// Skipped under tests, the extraction-trigger precedent: scripted
|
|||
|
|
// chats are fixtures — the service itself is tested directly with an
|
|||
|
|
// injected summarizer.
|
|||
|
|
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
|
|||
|
|
await awaitCompaction(chatId);
|
|||
|
|
const record = deps.chatStore.getChat(chatId);
|
|||
|
|
if (!record) return;
|
|||
|
|
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
|||
|
|
if (state().inFlight.has(chatId)) return; // a concurrent caller started it while we awaited
|
|||
|
|
const run = compact(deps, chatId);
|
|||
|
|
state().inFlight.set(chatId, run);
|
|||
|
|
try {
|
|||
|
|
await run;
|
|||
|
|
} finally {
|
|||
|
|
state().inFlight.delete(chatId);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The settle kick (fire-and-forget): the turn settled, the learner is
|
|||
|
|
* about to read/answer — if the projection is over threshold, the
|
|||
|
|
* summary starts NOW and finishes inside the pause. Wired at
|
|||
|
|
* chat-handler's onFinish tail; engineless or over-threshold-nothing
|
|||
|
|
* are silent no-ops. The next turn's ensureCompacted gate picks up
|
|||
|
|
* whatever this leaves running.
|
|||
|
|
*/
|
|||
|
|
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
|
|||
|
|
if (state().inFlight.has(chatId)) return;
|
|||
|
|
const deps = buildDeps();
|
|||
|
|
if (!deps) return;
|
|||
|
|
const record = deps.chatStore.getChat(chatId);
|
|||
|
|
if (!record) return;
|
|||
|
|
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
|||
|
|
const run = compact(deps, chatId);
|
|||
|
|
state().inFlight.set(chatId, run);
|
|||
|
|
void run.finally(() => state().inFlight.delete(chatId));
|
|||
|
|
}
|