From ed0d6b91b68b7bd180c19a92f49ee816ab0ef52f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 30 Sep 2026 18:23:17 +0200 Subject: [PATCH] feat(durable): compaction and overflow (Package 20) Manual compact(), background and blocking threshold compaction, and overflow compaction with one retry. Summaries are headed pi.compaction entries placed through the inbox, or appended by a compaction the generation waits on; the stale rule decides between concurrent summaries. --- packages/durable/CHANGELOG.md | 2 + packages/durable/README.md | 34 +- packages/durable/src/entries.ts | 7 +- packages/durable/src/harness/compaction.ts | 456 ++++ packages/durable/src/harness/config.ts | 17 +- packages/durable/src/harness/context.ts | 21 +- packages/durable/src/harness/events.ts | 29 +- packages/durable/src/harness/generation.ts | 163 +- packages/durable/src/harness/harness.ts | 27 +- packages/durable/src/harness/live.ts | 43 +- packages/durable/src/harness/registry.ts | 3 +- packages/durable/src/harness/submissions.ts | 135 +- packages/durable/src/harness/types.ts | 52 + packages/durable/src/harness/usage.ts | 6 +- packages/durable/src/index.ts | 22 +- .../durable/test/examples/25-compaction.ts | 118 + .../durable/test/harness-compaction.test.ts | 2253 +++++++++++++++++ .../durable/test/harness-registry.test.ts | 5 +- scripts/check-entry-graphs.mjs | 2 +- 19 files changed, 3285 insertions(+), 110 deletions(-) create mode 100644 packages/durable/src/harness/compaction.ts create mode 100644 packages/durable/test/examples/25-compaction.ts create mode 100644 packages/durable/test/harness-compaction.test.ts diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 706085720..e929f639e 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -37,6 +37,8 @@ - Added `Conversation.abort(context, { background: true })`, which also aborts background work and waits for it. - Conversation and Harness idle waits now include work in owned conversations and stop at background tasks. - A tool task whose `execute()` throws, or that is interrupted by a restart without a safe rerun, now ends `failed` (still with its error result entry), which aborts the conversations the call owns. +- Added compaction: the `pi.compaction` task (`CompactionTask`) summarizes an older prefix of the model context and places a `pi.compaction` entry (`CompactionEntry`) that heads the first kept entry. `Conversation.compact()` starts one manually; generation starts one in the background above a soft threshold, waits for one above `contextWindow - reserveTokens`, and compacts and retries once after a context overflow. Configure it with `get/setCompaction()`; `beforeCompact` hooks can decline or supply the summary; `pi.live.compactions`, `compaction_start`/`compaction_end` events, and `pi.usage` report it. +- `ContextView` now includes `contributions`, each active entry's model messages after edits. - Added `TaskRuntime.conversation()` and `ToolExecutionApi.conversation()`: invocation-bound `ConversationHandle`s for submitting to, aborting, and waiting on existing conversations, such as the ones a task owns. ### Fixed diff --git a/packages/durable/README.md b/packages/durable/README.md index ef5ddb0e2..9d04bc3a5 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -18,6 +18,7 @@ Built on [`@earendil-works/pi-ai`](../ai/README.md) for model access and `@earen - [Watching a Conversation](#watching-a-conversation) - [Busy Conversations](#busy-conversations) - [Reset and Handoff](#reset-and-handoff) +- [Compaction](#compaction) - [Agent Events (Experimental)](#agent-events-experimental) - [Hooks](#hooks) - [More Conversations and Forks](#more-conversations-and-forks) @@ -234,6 +235,36 @@ await root.reset("We were fixing the flaky login test. Continue.", context); // While busy, the reset is queued like a write. When it is placed during a tool round, the current run ends. A tool can request the same with `control: { handoff: "..." }`. +## Compaction + +Compaction shrinks what the model sees: it summarizes older entries and appends a `pi.compaction` entry that holds the summary and heads the first entry it keeps. Older entries stay in storage. + +```typescript +const id = await root.compact("Keep the failing test names", context); // manual, with optional instructions +const { outcome } = (await harness.waitForTask(id, context)).state; +if (outcome.status === "completed" && outcome.result.submissionId !== undefined) { + const placed = await (await harness.submission(outcome.result.submissionId, context))!.wait(context); + console.log(placed.status); // "done", or "unanswered" with reason "stale" +} +``` + +The conversation keeps working while the summary is made. The summary is placed at once when the conversation is idle, otherwise at the next turn boundary. Esc (`abort()`) cancels a manual compaction. + +Generation also compacts on its own, controlled per conversation: + +```typescript +await root.setCompaction({ + enabled: true, // automatic compaction; manual compact() always works + reserveTokens: 16384, // above contextWindow - reserveTokens, the next request waits for a compaction + keepRecentTokens: 20000, // roughly how much recent context stays verbatim + backgroundTokens: 32768, // this far below that, a compaction starts in the background; 0 disables it +}, context); +``` + +When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as `stale` when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in `pi.usage`. A `beforeCompact` hook on `CompactionTask` can decline or supply its own summary. + +Running compactions are listed in `docs["pi.live"].compactions` with their reason, attempt, and retry backoff. The agent events add `compaction_start` and `compaction_end`, and a `compactions` field in the snapshot. + ## Agent Events (Experimental) For consumers that want coding-agent style events (`message_start`, `message_update`, `tool_execution_start`, ...) instead of structural state: @@ -242,7 +273,7 @@ For consumers that want coding-agent style events (`message_start`, `message_upd import { watchEvents } from "@earendil-works/pi-durable"; const stream = await watchEvents(harness, root.id, context); -initialize(stream.snapshot); // entries, run, in-flight generation, tools, inbox, config, usage +initialize(stream.snapshot); // entries, run, in-flight generation, tools, compactions, inbox, config, usage stream.start(async (events) => { for (const event of events) console.log(JSON.stringify(event)); }); @@ -426,6 +457,7 @@ node --conditions=source --experimental-strip-types test/examples/14-chat.ts | [22-subagent-foreground](test/examples/22-subagent-foreground.ts) | A replay-safe subagent tool whose child the call owns, with the child's events under the call | | [23-subagent-background](test/examples/23-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe | | [24-child-tasks](test/examples/24-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart | +| [25-compaction](test/examples/25-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow | | [00](test/examples/00-conversation.ts)–[13](test/examples/13-recovery.ts) | The layers underneath: sessions, documents, forks, watches, tasks, recovery | Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider otherwise. diff --git a/packages/durable/src/entries.ts b/packages/durable/src/entries.ts index ab6701e4f..95bb6aff1 100644 --- a/packages/durable/src/entries.ts +++ b/packages/durable/src/entries.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "@earendil-works/chord"; -import type { ToolDiagnostic } from "./harness/types.ts"; +import type { CompactionReason, ToolDiagnostic } from "./harness/types.ts"; import type { Entry, EntryRecord, TypedEntry } from "./types.ts"; /** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */ @@ -27,3 +27,8 @@ export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("p * handoff text. Written by `Conversation.reset()` and the `handoff` tool control. */ export const ResetEntry = defineEntry("pi.reset"); +/** + * Compaction summary: `model` is `[UserMessage]` with the wrapped summary, `head` the first kept entry. Written by + * compaction tasks, directly or through a write submission. + */ +export const CompactionEntry = defineEntry<{ reason: CompactionReason }>("pi.compaction"); diff --git a/packages/durable/src/harness/compaction.ts b/packages/durable/src/harness/compaction.ts new file mode 100644 index 000000000..f16a4c11e --- /dev/null +++ b/packages/durable/src/harness/compaction.ts @@ -0,0 +1,456 @@ +import type { Context, Draft } from "@earendil-works/chord"; +import type { AssistantMessage, Message, ModelThinkingLevel, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import { calculateContextTokens, estimateMessageTokens } from "@earendil-works/pi-ai/utils/estimate"; +import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry"; +import { CompactionEntry } from "../entries.ts"; +import type { Transaction } from "../session/transaction.ts"; +import { defineTask } from "../tasks.ts"; +import type { + ConversationId, + EntryDraft, + EntryId, + NextTaskState, + RunningTask, + TaskId, + TaskRuntime, + Tx, +} from "../types.ts"; +import { ConversationConfig, DEFAULT_COMPACTION_POLICY, DEFAULT_RETRY_POLICY } from "./config.ts"; +import { orderToolResults } from "./context.ts"; +import { addCompactionStatus, compactionStatus, LiveDoc, type LiveState, removeCompactionStatus } from "./live.ts"; +import { admitSubmission } from "./submissions.ts"; +import type { + CompactionHooks, + CompactionReason, + CompactionResult, + ContextView, + ConversationStreamOptions, + ModelRef, +} from "./types.ts"; +import { recordUsage } from "./usage.ts"; + +export type CompactionInput = { reason: CompactionReason; instructions?: string }; + +/** The pinned summarization request. */ +export type SummaryRequest = { + attempt: number; + model: ModelRef; + thinkingLevel: ModelThinkingLevel; + streamOptions: ConversationStreamOptions; + maxTokens: number; + /** Newest entry of the context the range was selected from. */ + tail: EntryId; + /** First entry kept verbatim; the summary's `head`. */ + firstKept: EntryId; +}; + +export type CompactionCheckpoint = + | { phase: "select" } + | ({ phase: "summarize" } & SummaryRequest) + | ({ phase: "retry"; until: number } & SummaryRequest); + +type Runtime = TaskRuntime; +type Next = NextTaskState; + +/** Longest tool result text a serialized summary source keeps. */ +const TOOL_RESULT_MAX_CHARS = 2000; + +const SUMMARY_PREFIX = + "The conversation history before this point was compacted into the following summary:\n\n\n"; +const SUMMARY_SUFFIX = "\n"; + +const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. If the conversation starts with an earlier summary, preserve its information and fold the newer messages into it. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** + * Built-in compaction task (spec §8.7): select an old prefix of the model context, summarize it, and place a summary + * entry whose `head` is the first kept entry. A compaction the generation owns blocks it and appends directly; a + * conversation-owned one places its summary through a write submission. + */ +export const CompactionTask = defineTask({ + name: "pi.compaction", + version: 1, + initial: () => ({ phase: "select" }), + phases: { + select: async (task, runtime, context) => { + const { conversationId } = runtime; + const config = + (await runtime.snapshot(ConversationConfig, conversationId, context)) ?? + ConversationConfig.definition.initial(); + const ref = config.model; + const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId); + if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context); + const policy = config.compaction ?? DEFAULT_COMPACTION_POLICY; + const view = await runtime.context(conversationId, context); + const cut = selectCut(view, policy.keepRecentTokens); + if (cut === undefined) return complete(runtime, context); + const firstKept = view.entries[cut]!.id; + const { reason, instructions } = task.input; + const compaction = { + reason, + entries: view.entries.slice(0, cut), + messages: summarizedMessages(view, cut), + firstKept, + ...(instructions === undefined ? {} : { instructions }), + }; + let decision: { readonly decline: true } | { readonly summary: string } | undefined; + await runtime.hooks.each("beforeCompact", async (hook) => { + if (decision === undefined) decision = await hook(compaction, runtime, context); + }); + if (decision !== undefined && "decline" in decision) return complete(runtime, context); + if (decision !== undefined) return place(runtime, firstKept, decision.summary, context); + const request: SummaryRequest = { + attempt: 1, + model: ref, + thinkingLevel: config.thinkingLevel, + streamOptions: config.streamOptions ?? {}, + maxTokens: Math.min( + Math.floor(0.8 * policy.reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ), + tail: view.entries.reduce((tail, entry) => (entry.id > tail ? entry.id : tail), firstKept), + firstKept, + }; + await runtime.commit(() => ({ status: "running", checkpoint: { phase: "summarize", ...request } }), context); + }, + summarize: async (task, runtime, context) => { + const { phase: _, ...request } = task.state.checkpoint; + const { model: ref, thinkingLevel, streamOptions, maxTokens, tail, firstKept, attempt } = request; + const model = runtime.models.getModel(ref.provider, ref.modelId); + if (model === undefined) return failNoModel(runtime, ref, context); + // The context at `tail` is immutable, so this is the range `select` chose. + const view = await runtime.context(runtime.conversationId, context, tail); + const cut = view.entries.findIndex((entry) => entry.id === firstKept); + const now = runtime.now(); + const messages: Message[] = [ + { role: "system", content: SUMMARIZATION_SYSTEM_PROMPT, timestamp: now }, + { + role: "user", + content: [{ type: "text", text: summaryPrompt(summarizedMessages(view, cut), task.input.instructions) }], + timestamp: now, + }, + ]; + const { deferred: _deferred, ...forwarded } = streamOptions; + const options: SimpleStreamOptions = { + ...forwarded, + cacheRetention: "none", + maxTokens, + signal: runtime.signal, + ...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }), + }; + const message = await runtime.models.completeSimple(model, { messages }, options); + // An abort mark or close: the abort invocation or the reopened task handles the committed state. + runtime.signal.throwIfAborted(); + const summary = summaryText(message); + const policy = + (await runtime.snapshot(ConversationConfig, runtime.conversationId, context))?.retry ?? + DEFAULT_RETRY_POLICY; + const retry = + message.stopReason === "error" && + isRetryableAssistantError(message) && + policy.enabled && + attempt <= policy.maxRetries; + const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0; + await runtime.commit(async (tx, current): Promise => { + await recordUsage( + tx, + runtime.conversationId, + "models", + `${message.provider}/${message.model}`, + message.usage, + ); + const live = await tx.doc(LiveDoc, runtime.conversationId); + if (summary !== undefined) return placeSummary(tx, runtime, current, live, firstKept, summary); + if (retry) { + const status = compactionStatus(live, runtime.taskId); + if (status !== undefined) status.retry = { at: until, error: message.errorMessage ?? "" }; + return { status: "running", checkpoint: { phase: "retry", ...request, until } }; + } + removeCompactionStatus(live, runtime.taskId); + const text = summaryFailure(message); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } }, + }; + }, context); + }, + retry: async (task, runtime, context) => { + const { phase: _, until, ...request } = task.state.checkpoint; + await runtime.sleep(until, context); + const attempt = request.attempt + 1; + await runtime.commit(async (tx) => { + const status = compactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + if (status !== undefined) { + status.attempt = attempt; + delete status.retry; + } + return { status: "running", checkpoint: { phase: "summarize", ...request, attempt } }; + }, context); + }, + }, + abort: async (_task, runtime, context) => { + await runtime.commit(async (tx) => { + removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + return { status: "terminal", outcome: { status: "aborted" } }; + }, context); + }, +}); + +/** + * Create a compaction task with its status in this commit. `owner` is the generation that waits for it (a blocking + * compaction); without one it is conversation-owned, and `background` unless it is manual. + */ +export async function createCompaction( + tx: Tx, + conversationId: ConversationId, + input: CompactionInput, + owner?: TaskId, +): Promise> { + const ownership = + owner === undefined ? ({ kind: "conversation" } as const) : { kind: "task" as const, taskId: owner }; + const background = owner === undefined && input.reason !== "manual"; + const taskId = await tx.createTask(CompactionTask, input, { ownership, conversationId, background }); + const status = { taskId, reason: input.reason, blocking: owner !== undefined, attempt: 1 }; + addCompactionStatus(await tx.doc(LiveDoc, conversationId), status); + return taskId; +} + +/** + * Index in `view.entries` of the first entry a summary keeps, or `undefined` when there is nothing to compact + * (spec §8.7). Walks back from the tail until `keepRecentTokens` are kept, then cuts at the first candidate at or after + * that entry: an entry whose contribution starts with a user or assistant message, never a tool result, and never a + * user entry that a result of the preceding assistant's calls still follows. + */ +export function selectCut(view: ContextView, keepRecentTokens: number): number | undefined { + const { contributions } = view; + const start = view.head === undefined ? 0 : 1; + const candidates: number[] = []; + for (let index = start; index < contributions.length; index++) { + if (isCandidate(contributions, index)) candidates.push(index); + } + let kept = 0; + let cut: number | undefined; + for (let index = contributions.length - 1; index >= start; index--) { + for (const message of contributions[index]!) kept += estimateMessageTokens(message); + if (kept < keepRecentTokens) continue; + cut = candidates.find((candidate) => candidate >= index) ?? candidates.at(-1); + break; + } + if (cut === undefined) return undefined; + for (let index = start; index < cut; index++) if (contributions[index]!.length > 0) return cut; + return undefined; +} + +function isCandidate(contributions: ContextView["contributions"], index: number): boolean { + const first = contributions[index]![0]; + if (first?.role === "assistant") return true; + if (first?.role !== "user") return false; + // A result of the preceding assistant's calls that follows this entry, before the next assistant, belongs before it. + let calls: ReadonlySet = new Set(); + for (let before = index - 1; before >= 0; before--) { + const assistant = contributions[before]!.findLast((message) => message.role === "assistant"); + if (assistant === undefined) continue; + calls = new Set(assistant.content.flatMap((content) => (content.type === "toolCall" ? [content.id] : []))); + break; + } + if (calls.size === 0) return true; + for (let after = index; after < contributions.length; after++) { + for (const [position, message] of contributions[after]!.entries()) { + if (message.role === "assistant" && (after > index || position > 0)) return true; + if (message.role === "toolResult" && calls.has(message.toolCallId)) return false; + } + } + return true; +} + +/** Model messages of the entries before `cut`: the head marker first, ordered like model context (spec §2.1). */ +export function summarizedMessages(view: ContextView, cut: number): Message[] { + return orderToolResults(view.contributions.slice(0, cut).flat()); +} + +/** + * Size of a request over `view` followed by `extra` (spec §8.3): the usage of the newest assistant appended after the + * head marker, whose request included the marker, plus estimates of the messages after it; without one, estimates of + * every message. + */ +export function estimateContext(view: ContextView, extra: readonly Message[]): number { + let measured: AssistantMessage | undefined; + const after = view.head?.id ?? Number.NEGATIVE_INFINITY; + for (let index = view.entries.length - 1; index >= 0 && measured === undefined; index--) { + if (view.entries[index]!.id <= after) continue; + measured = view.contributions[index]!.findLast( + (message): message is AssistantMessage => + message.role === "assistant" && calculateContextTokens(message.usage) > 0, + ); + } + const from = measured === undefined ? 0 : view.messages.lastIndexOf(measured) + 1; + let tokens = measured === undefined ? 0 : calculateContextTokens(measured.usage); + for (const message of view.messages.slice(from)) tokens += estimateMessageTokens(message); + for (const message of extra) tokens += estimateMessageTokens(message); + return tokens; +} + +/** The summary of a clean `stop` with text and no tool call; anything else is not a summary. */ +function summaryText(message: AssistantMessage): string | undefined { + if (message.stopReason !== "stop" || message.content.some((content) => content.type === "toolCall")) + return undefined; + const text = message.content + .flatMap((content) => (content.type === "text" ? [content.text] : [])) + .join("\n") + .trim(); + return text.length === 0 ? undefined : text; +} + +function summaryFailure(message: AssistantMessage): string { + if (message.stopReason === "error" || message.stopReason === "aborted") { + return `Summarization failed: ${message.errorMessage ?? message.stopReason}`; + } + if (message.stopReason === "length") return "Summarization hit the token limit; the summary is incomplete"; + if (message.content.some((content) => content.type === "toolCall")) return "Summarization attempted to call a tool"; + return "Summarization produced no text"; +} + +/** The summarizer's user message: the serialized conversation, the prompt, and any instructions. */ +function summaryPrompt(messages: readonly Message[], instructions: string | undefined): string { + const focus = instructions === undefined ? "" : `\n\nAdditional focus: ${instructions}`; + return `\n${serializeConversation(messages)}\n\n\n${SUMMARIZATION_PROMPT}${focus}`; +} + +/** Messages as plain text, so the summarizer reads a transcript instead of continuing it. System messages are omitted. */ +export function serializeConversation(messages: readonly Message[]): string { + const parts: string[] = []; + for (const message of messages) { + if (message.role === "user") { + const text = contentText(message.content); + if (text.length > 0) parts.push(`[User]: ${text}`); + } else if (message.role === "assistant") { + const thinking = message.content.flatMap((content) => (content.type === "thinking" ? [content.thinking] : [])); + const text = message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])); + const calls = message.content.flatMap((content) => + content.type === "toolCall" + ? [ + `${content.name}(${Object.entries(content.arguments) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", ")})`, + ] + : [], + ); + if (thinking.length > 0) parts.push(`[Assistant thinking]: ${thinking.join("\n")}`); + if (text.length > 0) parts.push(`[Assistant]: ${text.join("\n")}`); + if (calls.length > 0) parts.push(`[Assistant tool calls]: ${calls.join("; ")}`); + } else if (message.role === "toolResult") { + const text = contentText(message.content); + if (text.length > 0) parts.push(`[Tool result]: ${truncate(text, TOOL_RESULT_MAX_CHARS)}`); + } + } + return parts.join("\n\n"); +} + +function contentText(content: string | readonly { readonly type: string; readonly text?: string }[]): string { + if (typeof content === "string") return content; + return content + .flatMap((block) => (block.type === "text" && block.text !== undefined ? [block.text] : [])) + .join("\n"); +} + +function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n\n[... ${text.length - maxChars} more characters truncated]`; +} + +/** Place a summary supplied by a hook in its own commit. */ +async function place(runtime: Runtime, firstKept: EntryId, summary: string, context: Context): Promise { + await runtime.commit( + async (tx, current) => + placeSummary(tx, runtime, current, await tx.doc(LiveDoc, runtime.conversationId), firstKept, summary), + context, + ); +} + +/** + * Place the summary entry and complete (spec §8.7). A blocking compaction, owned by its generation, appends it: the + * generation holds the run and waits. A conversation-owned one admits it as a write submission, placed at once when + * idle, otherwise at the next boundary, or settled `stale`. + * REMINDER: nothing else may append to a busy conversation, so every non-blocking summary goes through admission. + */ +async function placeSummary( + tx: Tx, + runtime: Runtime, + current: RunningTask, + live: Draft, + firstKept: EntryId, + summary: string, +): Promise { + removeCompactionStatus(live, runtime.taskId); + const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`; + const entry = { + kind: CompactionEntry.kind, + head: firstKept, + model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }], + data: { reason: current.input.reason }, + } satisfies EntryDraft; + const result: CompactionResult = + current.owner === undefined + ? { + // The scheduler commits runtime changes through a Session transaction, which admission needs. + submissionId: await admitSubmission( + tx as Transaction, + runtime.conversationId, + { type: "write", requestId: `compaction:${runtime.taskId}`, entry }, + runtime.now(), + ), + } + : { entryId: (await tx.appendEntry(runtime.conversationId, entry)).id }; + return { status: "terminal", outcome: { status: "completed", result } }; +} + +/** Remove the status and complete without a summary. */ +async function complete(runtime: Runtime, context: Context): Promise { + await runtime.commit(async (tx) => { + removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + return { status: "terminal", outcome: { status: "completed", result: {} } }; + }, context); +} + +async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context: Context): Promise { + const message = + ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`; + await runtime.commit(async (tx) => { + removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } }; + }, context); +} diff --git a/packages/durable/src/harness/config.ts b/packages/durable/src/harness/config.ts index c01c87e71..69c3cf53d 100644 --- a/packages/durable/src/harness/config.ts +++ b/packages/durable/src/harness/config.ts @@ -1,6 +1,12 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; import { defineDoc } from "../documents.ts"; -import type { ConversationRetryPolicy, ConversationStreamOptions, QueueMode, ToolExecutionMode } from "./types.ts"; +import type { + CompactionPolicy, + ConversationRetryPolicy, + ConversationStreamOptions, + QueueMode, + ToolExecutionMode, +} from "./types.ts"; /** Durable per-conversation model, thinking level, request options, and desired tool loadout. */ export type ConversationConfigState = { @@ -18,6 +24,8 @@ export type ConversationConfigState = { steeringMode?: QueueMode; /** How many queued follow-ups a final boundary places; absent means `one-at-a-time`. */ followUpMode?: QueueMode; + /** Automatic compaction thresholds; absent uses `DEFAULT_COMPACTION_POLICY`. */ + compaction?: CompactionPolicy; }; export const DEFAULT_RETRY_POLICY: ConversationRetryPolicy = { @@ -27,6 +35,13 @@ export const DEFAULT_RETRY_POLICY: ConversationRetryPolicy = { maxAgentDelayMs: 60000, }; +export const DEFAULT_COMPACTION_POLICY: CompactionPolicy = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, + backgroundTokens: 32768, +}; + /** Built-in configuration document; rewindable so forks start from the configuration at their fork entry. */ export const ConversationConfig = defineDoc({ kind: "pi.conversation.config", diff --git a/packages/durable/src/harness/context.ts b/packages/durable/src/harness/context.ts index 507572e23..8dd584f05 100644 --- a/packages/durable/src/harness/context.ts +++ b/packages/durable/src/harness/context.ts @@ -62,24 +62,23 @@ export async function deriveContext( bounds: ContextBounds | undefined, context: Context, ): Promise { - if (bounds === undefined) return { head: undefined, entries: [], messages: [] }; + if (bounds === undefined) return { head: undefined, entries: [], contributions: [], messages: [] }; const head = bounds.head; const range = await scanRange(storage, conversationId, bounds, context); const edits = new Map(); + // Edits of every entry in the range count, including older head markers that `selectActive()` drops. for (const entry of range) for (const edit of entry.edits ?? []) edits.set(edit.target, edit); const entries = selectActive(head, range); - const messages: Message[] = []; - for (const entry of entries) { + const contributions = entries.map((entry): Message[] => { const edit = edits.get(entry.id); - if (edit?.action === "omit") continue; + if (edit?.action === "omit") return []; const contributed = edit?.action === "replace" ? edit.messages : (entry.model ?? []); - for (const message of contributed) { - if (message.role === "assistant" && EXCLUDED_STOP_REASONS.has(message.stopReason)) continue; - messages.push(message); - } - } - return { head, entries, messages: orderToolResults(messages) }; + return contributed.filter( + (message) => message.role !== "assistant" || !EXCLUDED_STOP_REASONS.has(message.stopReason), + ); + }); + return { head, entries, contributions, messages: orderToolResults(contributions.flat()) }; } /** The raw active entries within captured bounds, without deriving model context. */ @@ -127,7 +126,7 @@ function selectActive(head: ContextBounds["head"], range: EntryRecord[]): EntryR * Place each assistant's tool results directly after it in call order. Results are taken from the messages before * the next assistant; a missing result is synthesized and unmatched results are dropped. */ -function orderToolResults(messages: readonly Message[]): Message[] { +export function orderToolResults(messages: readonly Message[]): Message[] { const ordered: Message[] = []; for (let index = 0; index < messages.length; index++) { const message = messages[index]!; diff --git a/packages/durable/src/harness/events.ts b/packages/durable/src/harness/events.ts index 053d7d80a..dd3de87ce 100644 --- a/packages/durable/src/harness/events.ts +++ b/packages/durable/src/harness/events.ts @@ -15,8 +15,8 @@ import type { } from "../types.ts"; import { ConversationConfig, type ConversationConfigState } from "./config.ts"; import type { InboxItem, InboxState } from "./inbox.ts"; -import type { LiveState, ToolSlot } from "./live.ts"; -import type { Harness, ToolDiagnostic } from "./types.ts"; +import type { CompactionStatus, LiveState, ToolSlot } from "./live.ts"; +import type { CompactionReason, Harness, ToolDiagnostic } from "./types.ts"; import { UsageDoc, type UsageState } from "./usage.ts"; import { scanAll } from "./util.ts"; import { type ConversationView, conversationViews } from "./view.ts"; @@ -44,6 +44,8 @@ export type SnapshotEvent = { deferred?: { pollAt: number }; }; tools: readonly ToolSlot[]; + /** `pi.live.compactions`: live compactions with their attempt and retry backoff. */ + compactions: readonly CompactionStatus[]; inbox: readonly QueuedItem[]; config: ConversationConfigState; usage: UsageState; @@ -80,7 +82,10 @@ export type AgentEvent = | { type: "entry_appended"; entry: EntryRecord } | { type: "config_changed"; config: ConversationConfigState } | { type: "usage_changed"; usage: UsageState } - | { type: "task_failed"; taskId: TaskId; kind: string; message: string }; + | { type: "task_failed"; taskId: TaskId; kind: string; message: string } + | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean } + /** The task's receipt tells whether it produced a summary; the summary entry has its own events. */ + | { type: "compaction_end"; taskId: TaskId; reason: CompactionReason }; /** Serialized stream of one conversation's event batches, one per commit. */ export interface AgentEventStream { @@ -116,6 +121,7 @@ function snapshotOf(view: ConversationView): SnapshotEvent { ...(live.run === undefined ? {} : { run: { inputs: live.run.inputs } }), ...(live.generation === undefined ? {} : { generation: live.generation as SnapshotEvent["generation"] }), tools: live.tools ?? [], + compactions: live.compactions ?? [], inbox: queued(inbox), config: config ?? ConversationConfig.definition.initial(), usage: usage ?? UsageDoc.definition.initial(), @@ -287,8 +293,16 @@ function translate( // Ends without a result entry: a faulted or orphaned tool, or one whose run ended. events.push(...toolEnds.filter((end) => end.entry === undefined)); - // Task failures, then turn and run ends. A generation's turn ends when its outcome is committed: at a `completing` - // hold or at terminal, whichever comes first, so a successor created at the hold starts after it. + // Compaction ends, task failures, then turn and run ends. + const compactionsBefore = was.live.compactions ?? []; + const compactions = now.live.compactions ?? []; + for (const { taskId, reason } of compactionsBefore) { + if (!compactions.some((status) => status.taskId === taskId)) { + events.push({ type: "compaction_end", taskId, reason }); + } + } + // A generation's turn ends when its outcome is committed: at a `completing` hold or at terminal, whichever comes + // first, so a successor created at the hold starts after it. let turnEnded = false; for (const task of tasks.values()) { const status = task.state.status; @@ -319,6 +333,11 @@ function translate( } if (now.usage !== was.usage) events.push({ type: "usage_changed", usage: now.usage ?? UsageDoc.definition.initial() }); + for (const { taskId, reason, blocking } of compactions) { + if (!compactionsBefore.some((status) => status.taskId === taskId)) { + events.push({ type: "compaction_start", taskId, reason, blocking }); + } + } if (run !== undefined && runChanged) events.push({ type: "run_start", inputs: run.inputs }); if (run !== undefined && run.taskId !== runBefore?.taskId && tasks.get(run.taskId)?.kind === "pi.generation") { events.push({ type: "turn_start" }); diff --git a/packages/durable/src/harness/generation.ts b/packages/durable/src/harness/generation.ts index 959999eba..4e81889d9 100644 --- a/packages/durable/src/harness/generation.ts +++ b/packages/durable/src/harness/generation.ts @@ -9,6 +9,7 @@ import type { SimpleStreamOptions, ToolCall, } from "@earendil-works/pi-ai"; +import { isContextOverflow } from "@earendil-works/pi-ai/utils/overflow"; import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry"; import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; import { AssistantEntry, ResetEntry, SystemEntry, UserEntry } from "../entries.ts"; @@ -23,13 +24,17 @@ import type { Tx, TypedEntry, } from "../types.ts"; -import { ConversationConfig, DEFAULT_RETRY_POLICY } from "./config.ts"; +import { createCompaction, estimateContext, selectCut } from "./compaction.ts"; +import { ConversationConfig, DEFAULT_COMPACTION_POLICY, DEFAULT_RETRY_POLICY } from "./config.ts"; import { applyBoundary, prepareBoundary } from "./inbox.ts"; import { assignJson } from "./json.ts"; import { endRun, LiveDoc, type LiveState, type ToolSlot } from "./live.ts"; import { desiredTools, planSystemEntries, renderSections, replaySections } from "./prompt.ts"; import { appendToolResult, harnessError, ToolTask, type ToolTaskResult } from "./tool.ts"; import type { + CompactionPolicy, + CompactionResult, + ContextView, ConversationStreamOptions, GenerationHooks, ModelRef, @@ -44,10 +49,18 @@ import { recordUsage } from "./usage.ts"; export type GenerationInput = Record; export type GenerationCheckpoint = - | { phase: "prepare"; attempt: number } + | { + phase: "prepare"; + attempt: number; + /** The blocking compaction this generation waited for; it starts no other compaction (spec §8.3). */ + compacted?: TaskId; + /** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */ + overflow?: string; + } | { phase: "request"; attempt: number; + compacted?: TaskId; model: ModelRef; thinkingLevel: ModelThinkingLevel; /** Configured request options when preparation committed; a resend after recovery uses them unchanged. */ @@ -56,10 +69,11 @@ export type GenerationCheckpoint = /** Newest entry included in the request. */ cutoff: EntryId; } - | { phase: "retry"; attempt: number; until: number } + | { phase: "retry"; attempt: number; compacted?: TaskId; until: number } | { phase: "poll"; attempt: number; + compacted?: TaskId; model: ModelRef; toolExecution: ToolExecutionMode; cutoff: EntryId; @@ -85,6 +99,7 @@ type Next = NextTaskState; /** What classification needs from the request that produced a message. */ type Request = { readonly attempt: number; + readonly compacted: TaskId | undefined; readonly model: ModelRef; readonly toolExecution: ToolExecutionMode; readonly cutoff: EntryId; @@ -118,8 +133,14 @@ export const GenerationTask = defineTask runtime.report(error); const desired = await renderSections(registry.sections(), input, shown, report, context); const entries = planSystemEntries(view, desired, tools, runtime.now()); + const threshold = + compacted === undefined + ? thresholdCompaction( + view, + entries, + resolved.contextWindow, + config.compaction ?? DEFAULT_COMPACTION_POLICY, + ) + : undefined; + if (threshold === "blocking") { + // Compact first and prepare again; the transcript is unchanged until the compaction appends. + await runtime.commit(async (tx): Promise => { + const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId); + const checkpoint = { phase: "prepare", attempt, compacted: child } as const; + return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; + }, context); + return; + } await runtime.commit(async (tx) => { let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id; for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id; if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`); + // Checked in this commit, so a compaction admitted during preparation counts. + if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) { + await createCompaction(tx, conversationId, { reason: "threshold" }); + } const request = { - attempt: task.state.checkpoint.attempt, + attempt, + ...(compacted === undefined ? {} : { compacted }), model, thinkingLevel, streamOptions: streamOptions ?? {}, @@ -151,7 +195,15 @@ export const GenerationTask = defineTask { - const { attempt, model: ref, thinkingLevel, streamOptions, toolExecution, cutoff } = task.state.checkpoint; + const { + attempt, + compacted, + model: ref, + thinkingLevel, + streamOptions, + toolExecution, + cutoff, + } = task.state.checkpoint; const conversationId = runtime.conversationId; await runtime.commit(async (tx) => { const live = await tx.doc(LiveDoc, conversationId); @@ -173,24 +225,30 @@ export const GenerationTask = defineTask { - const { attempt, until } = task.state.checkpoint; + const { attempt, compacted, until } = task.state.checkpoint; await runtime.sleep(until, context); await runtime.commit(async (tx) => { (await tx.doc(LiveDoc, runtime.conversationId)).generation = { attempt: attempt + 1 }; - return { status: "running", checkpoint: { phase: "prepare", attempt: attempt + 1 } }; + const checkpoint: GenerationCheckpoint = { + phase: "prepare", + attempt: attempt + 1, + ...(compacted === undefined ? {} : { compacted }), + }; + return { status: "running", checkpoint }; }, context); }, poll: async (task, runtime, context) => { - const { attempt, model: ref, toolExecution, cutoff, handle, pollAt } = task.state.checkpoint; + const { attempt, compacted, model: ref, toolExecution, cutoff, handle, pollAt } = task.state.checkpoint; const model = runtime.models.getModel(ref.provider, ref.modelId); if (model === undefined) return failNoModel(runtime, ref, context); await runtime.sleep(pollAt, context); const message = await runtime.models.fetchDeferred(model, handle, { signal: runtime.signal }); - await classify(runtime, { attempt, model: ref, toolExecution, cutoff, pollAt }, message, context); + const request = { attempt, compacted, model: ref, toolExecution, cutoff, pollAt }; + await classify(runtime, request, message, context); }, tools: async (task, runtime, context) => { const { assistant, tools, pending } = task.state.checkpoint; @@ -260,6 +318,42 @@ function createToolTask(tx: Tx, runtime: Runtime, assistant: EntryId, callId: st return tx.createTask(ToolTask, { assistant, callId }, { ownership: { kind: "task", taskId: runtime.taskId } }); } +/** + * Which threshold compaction preparation starts before its request (spec §8.3): `blocking` above + * `contextWindow - reserveTokens`, `background` above the background threshold, and only when range selection finds a + * cut. The caller starts a background one only while no compaction is listed. + */ +function thresholdCompaction( + view: ContextView, + planned: readonly { readonly model?: readonly Message[] }[], + contextWindow: number, + policy: CompactionPolicy, +): "blocking" | "background" | undefined { + if (!policy.enabled || contextWindow <= 0) return undefined; + const tokens = estimateContext( + view, + planned.flatMap((entry) => entry.model ?? []), + ); + const blocking = contextWindow - policy.reserveTokens; + const background = blocking - policy.backgroundTokens; + const over = + tokens > blocking ? "blocking" : policy.backgroundTokens > 0 && tokens > background ? "background" : undefined; + if (over === undefined || selectCut(view, policy.keepRecentTokens) === undefined) return undefined; + return over; +} + +/** Settle the run's inputs `unanswered` with `model_error` and fail with `text`. */ +async function failModelError(runtime: Runtime, text: string, context: Context): Promise { + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, runtime.conversationId); + endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text }); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } }, + }; + }, context); +} + /** Settle the run's inputs `unanswered` with `no_model` and fail. */ async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context: Context): Promise { const message = @@ -349,7 +443,7 @@ async function classify( // An abort mark or close: the abort invocation or the reopened run handles the committed state. runtime.signal.throwIfAborted(); const conversationId = runtime.conversationId; - const { attempt, model: ref, toolExecution, cutoff } = request; + const { attempt, compacted, model: ref, toolExecution, cutoff } = request; if (message.stopReason === "deferred" && message.deferred !== undefined) { const handle = message.deferred; const pollAt = Math.max( @@ -358,7 +452,16 @@ async function classify( ); await runtime.commit(async (tx) => { (await tx.doc(LiveDoc, conversationId)).generation = { attempt, deferred: { pollAt } }; - const checkpoint = { phase: "poll", attempt, model: ref, toolExecution, cutoff, handle, pollAt } as const; + const checkpoint = { + phase: "poll", + attempt, + ...(compacted === undefined ? {} : { compacted }), + model: ref, + toolExecution, + cutoff, + handle, + pollAt, + } as const; return { status: "running", checkpoint }; }, context); return; @@ -371,10 +474,30 @@ async function classify( if (message.stopReason === "stop" || message.stopReason === "length" || message.stopReason === "toolUse") { return answer(runtime, message, context); } - // The retry policy governs the next attempt, so it is read now rather than pinned at preparation. - const policy = (await runtime.snapshot(ConversationConfig, conversationId, context))?.retry ?? DEFAULT_RETRY_POLICY; + // The retry and compaction policies govern the next attempt, so they are read now rather than pinned at preparation. + const config = await runtime.snapshot(ConversationConfig, conversationId, context); + const overflow = message.stopReason === "error" && isContextOverflow(message); + if (overflow && compacted === undefined && (config?.compaction ?? DEFAULT_COMPACTION_POLICY).enabled) { + const policy = config?.compaction ?? DEFAULT_COMPACTION_POLICY; + const view = await runtime.context(conversationId, context, cutoff); + if (selectCut(view, policy.keepRecentTokens) !== undefined) { + const text = message.errorMessage ?? "Context overflow"; + await runtime.commit(async (tx): Promise => { + const live = await tx.doc(LiveDoc, conversationId); + await appendAssistant(tx, conversationId, message); + delete live.generation; + const child = await createCompaction(tx, conversationId, { reason: "overflow" }, runtime.taskId); + const checkpoint = { phase: "prepare", attempt, compacted: child, overflow: text } as const; + return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; + }, context); + return; + } + } + const policy = config?.retry ?? DEFAULT_RETRY_POLICY; + // An overflow is never retried: only a compaction can make the next request fit. const retry = message.stopReason === "error" && + !overflow && isRetryableAssistantError(message) && policy.enabled && attempt <= policy.maxRetries; @@ -384,7 +507,13 @@ async function classify( await appendAssistant(tx, conversationId, message); if (retry) { live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } }; - return { status: "running", checkpoint: { phase: "retry", attempt, until } }; + const checkpoint = { + phase: "retry", + attempt, + ...(compacted === undefined ? {} : { compacted }), + until, + } as const; + return { status: "running", checkpoint }; } const text = message.errorMessage ?? `Model response ended with stop reason ${message.stopReason}`; endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text }); diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index 090a15727..1129d1df0 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -21,7 +21,13 @@ import type { WatchHandle, } from "../types.ts"; import { ROOT_CONVERSATION_ID } from "../types.ts"; -import { ConversationConfig, type ConversationConfigState, DEFAULT_RETRY_POLICY } from "./config.ts"; +import { createCompaction } from "./compaction.ts"; +import { + ConversationConfig, + type ConversationConfigState, + DEFAULT_COMPACTION_POLICY, + DEFAULT_RETRY_POLICY, +} from "./config.ts"; import { readContext } from "./context.ts"; import { withdrawQueuedInputs } from "./inbox.ts"; import { settleSchedulerOutcome } from "./live.ts"; @@ -29,6 +35,8 @@ import { BUILTIN_SETUP_KEY, BUILTIN_TASKS } from "./registry.ts"; import { type InvocationBinding, TaskScheduler } from "./scheduler.ts"; import { Submissions } from "./submissions.ts"; import type { + CompactionPolicy, + CompactionResult, ContextView, Conversation, ConversationAbortOptions, @@ -174,10 +182,27 @@ class ConversationImpl implements Conversation { }, context); } + async getCompaction(context: Context): Promise { + return (await this.#config(context)).compaction ?? { ...DEFAULT_COMPACTION_POLICY }; + } + + setCompaction(policy: CompactionPolicy | undefined, context: Context): Promise { + return this.#editConfig((config) => { + if (policy === undefined) delete config.compaction; + else config.compaction = policy; + }, context); + } + submit(submission: SubmissionDraft, context: Context): Promise { return this.#host.submissions.submit(this.id, submission, context); } + compact(instructions: string | undefined, context: Context): Promise> { + this.#host.tasks.resume(); + const input = { reason: "manual", ...(instructions === undefined ? {} : { instructions }) } as const; + return this.#host.harness.commitWith((tx) => createCompaction(tx, this.id, input), context); + } + async reset(handoff: string | undefined, context: Context): Promise { const model = handoff === undefined diff --git a/packages/durable/src/harness/live.ts b/packages/durable/src/harness/live.ts index ddea4ebaa..0191e0610 100644 --- a/packages/durable/src/harness/live.ts +++ b/packages/durable/src/harness/live.ts @@ -5,7 +5,7 @@ import type { Transaction } from "../session/transaction.ts"; import type { EntryId, SubmissionId, SubmissionSettlement, TaskId, TaskRecord, Tx } from "../types.ts"; import { convertPartial } from "./generation.ts"; import type { SchedulerOutcome } from "./scheduler.ts"; -import type { ToolDiagnostic } from "./types.ts"; +import type { CompactionReason, CompactionResult, ToolDiagnostic } from "./types.ts"; /** Presentation of one tool call of the current round. */ export type ToolSlot = { @@ -29,6 +29,17 @@ export type ToolSlot = { entry?: EntryId; }; +/** Presentation of one live compaction task (spec §8.7). */ +export type CompactionStatus = { + taskId: TaskId; + reason: CompactionReason; + /** Whether a generation waits for it: a compaction the generation owns. */ + blocking: boolean; + attempt: number; + /** Durable backoff before the next summarization attempt. */ + retry?: { at: number; error: string }; +}; + /** Built-in live conversation state: run control and presentation of the current generation and tool round. */ export type LiveState = { /** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */ @@ -45,6 +56,8 @@ export type LiveState = { }; /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */ tools?: ToolSlot[]; + /** Live compaction tasks in task ID order; absent when none. */ + compactions?: CompactionStatus[]; }; export const LiveDoc = defineDoc({ @@ -65,6 +78,7 @@ export const LiveDoc = defineDoc({ /** Built-in task kinds that can own `pi.live.run`. */ const RUN_TASK_KINDS: ReadonlySet = new Set(["pi.generation"]); const TOOL_TASK_KIND = "pi.tool"; +const COMPACTION_TASK_KIND = "pi.compaction"; /** * End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation` and `tools`, @@ -79,6 +93,26 @@ export function endRun(tx: Tx, live: Draft, taskId: TaskId, settlemen delete live.tools; } +/** Add the status of a compaction task created in this commit; statuses stay in task ID order. */ +export function addCompactionStatus(live: Draft, status: CompactionStatus): void { + live.compactions ??= []; + live.compactions.push(status); +} + +/** The status of compaction task `taskId`, if listed. */ +export function compactionStatus(live: Draft, taskId: TaskId): Draft | undefined { + return live.compactions?.find((status) => status.taskId === taskId); +} + +/** Remove the status of compaction task `taskId`, and the list once empty. */ +export function removeCompactionStatus(live: Draft, taskId: TaskId): void { + const statuses = live.compactions; + if (statuses === undefined) return; + const index = statuses.findIndex((status) => status.taskId === taskId); + if (index >= 0) statuses.splice(index, 1); + if (statuses.length === 0) delete live.compactions; +} + /** The slot of tool task `taskId` in the current round, if the round still lists it. */ export function toolSlot(live: Draft, taskId: TaskId): Draft | undefined { return live.tools?.find((slot) => slot.taskId === taskId); @@ -102,7 +136,8 @@ export function clearProgress(slot: Draft): void { /** * Harness cleanup for a terminal outcome the scheduler writes itself (`faulted` or `orphaned`). A run task ends its - * run; a tool task's slot is marked done without an entry, and context derivation synthesizes the missing result. + * run; a tool task's slot is marked done without an entry, and context derivation synthesizes the missing result; a + * compaction task's status is removed. * Ignores other kinds so it never creates `pi.live` elsewhere. The scheduler calls this without knowing task kinds; * the Harness passes it in (spec §5.4). * REMINDER: a committed generation partial becomes an aborted assistant entry here, exactly as in the generation abort @@ -121,6 +156,10 @@ export async function settleSchedulerOutcome( if (slot !== undefined) finishSlot(slot, undefined); return; } + if (record.kind === COMPACTION_TASK_KIND) { + removeCompactionStatus(await tx.doc(LiveDoc, record.conversationId), record.id); + return; + } if (!RUN_TASK_KINDS.has(record.kind)) return; const live = await tx.doc(LiveDoc, record.conversationId); if (live.run?.taskId !== record.id) return; diff --git a/packages/durable/src/harness/registry.ts b/packages/durable/src/harness/registry.ts index 04a62b449..83daa729a 100644 --- a/packages/durable/src/harness/registry.ts +++ b/packages/durable/src/harness/registry.ts @@ -1,4 +1,5 @@ import type { ConversationRecord, Tx } from "../types.ts"; +import { CompactionTask } from "./compaction.ts"; import { ConversationConfig } from "./config.ts"; import { GenerationTask } from "./generation.ts"; import { InboxDoc } from "./inbox.ts"; @@ -24,7 +25,7 @@ import { UsageDoc } from "./usage.ts"; const SECTION_KEY = /^[a-z][a-z0-9_-]*$/; /** Built-in task definitions every registry starts with; they cannot be disposed or replaced. */ -export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask]; +export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, CompactionTask]; export const BUILTIN_SETUP_KEY = "pi"; diff --git a/packages/durable/src/harness/submissions.ts b/packages/durable/src/harness/submissions.ts index 67155fd3a..eb8bfd357 100644 --- a/packages/durable/src/harness/submissions.ts +++ b/packages/durable/src/harness/submissions.ts @@ -2,6 +2,7 @@ import { type Context, copyJson, type JsonRepresentation } from "@earendil-works import { UserEntry } from "../entries.ts"; import { ConversationBusy } from "../errors.ts"; import type { SessionImpl } from "../session/session.ts"; +import type { Transaction } from "../session/transaction.ts"; import type { CommitPublication, ConversationId, @@ -40,70 +41,13 @@ export class Submissions { }); } - /** - * Admit a submission in one commit (spec §6). A known request ID returns its existing submission without writing. - * A busy conversation queues it in `pi.inbox`, or rejects `whenBusy: "reject"` input with `ConversationBusy` - * without writing. An idle conversation with queued items queues it behind them and runs a final boundary. Otherwise - * idle input places a user entry and starts a run, and an idle write appends its entry and settles `done`. - */ + /** Admit a submission in one commit; see `admitSubmission()`. */ async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise { this.#resume(); - const id = await this.#session.commitWith(async (tx) => { - if (draft.requestId !== undefined) { - const existing = await tx.submissionByRequest(conversationId, draft.requestId); - if (existing !== undefined) { - if (existing.type !== draft.type) { - throw new Error( - `Request ${draft.requestId} already identifies a submission of type ${existing.type}`, - ); - } - return existing.id; - } - } - const live = await tx.doc(LiveDoc, conversationId); - const busy = live.run !== undefined; - if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId); - const requestId = draft.requestId === undefined ? {} : { requestId: draft.requestId }; - // A boundary reads the table, so it is prepared before the first table write; a busy one needs none. - const boundary = busy ? undefined : await prepareBoundary(tx, conversationId); - if (boundary === undefined || boundary.inbox.items.length > 0) { - const { id } = await tx.createSubmission({ - conversationId, - ...requestId, - type: draft.type, - status: "queued", - }); - // Hosts may leave optional fields `undefined`; drafts take strict JSON. - const value = copyJson(draft.type === "write" ? draft.entry : draft.content, { - omitUndefinedProperties: true, - }); - const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items; - if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject }); - else { - const mode = draft.whenBusy === "steer" ? "steer" : "followUp"; - items.push({ id, mode, content: value as JsonRepresentation }); - } - if (boundary === undefined) return id; - const { users } = await applyBoundary(tx, boundary, "final", this.#now()); - if (users.length > 0) await startRun(tx, conversationId, live, users); - return id; - } - if (draft.type === "write") { - if (isStale(boundary, draft.entry)) { - const stale = { status: "unanswered", reason: "stale" } as const; - return (await tx.createSubmission({ conversationId, ...requestId, type: "write", ...stale })).id; - } - const entry = await tx.appendEntry(conversationId, draft.entry); - const write = { conversationId, ...requestId, type: "write", status: "done", entry: entry.id } as const; - return (await tx.createSubmission(write)).id; - } - const message = { role: "user", content: draft.content, timestamp: this.#now() } as const; - const entry = await tx.appendEntry(UserEntry, conversationId, { model: [message] }); - const input = { conversationId, ...requestId, type: "input", status: "placed", entry: entry.id } as const; - const { id } = await tx.createSubmission(input); - await startRun(tx, conversationId, live, [id]); - return id; - }, context); + const id = await this.#session.commitWith( + (tx) => admitSubmission(tx, conversationId, draft, this.#now()), + context, + ); return new SubmissionHandle(id, this); } @@ -184,3 +128,70 @@ class SubmissionHandle implements Submission { function isSettled(record: SubmissionRecord): record is SettledSubmissionRecord { return record.status === "done" || record.status === "unanswered"; } + +/** + * Admit a submission inside a commit (spec §6); `Conversation.submit()` and conversation-owned compactions share it. A + * known request ID returns its existing submission without writing. A busy conversation queues it in `pi.inbox`, or + * rejects `whenBusy: "reject"` input with `ConversationBusy`. An idle conversation with queued items queues it behind + * them and runs a final boundary. Otherwise idle input places a user entry and starts a run, and an idle write appends + * its entry and settles `done`, or `stale` when its head reaches before the active range. + */ +export async function admitSubmission( + tx: Transaction, + conversationId: ConversationId, + draft: SubmissionDraft, + now: number, +): Promise { + if (draft.requestId !== undefined) { + const existing = await tx.submissionByRequest(conversationId, draft.requestId); + if (existing !== undefined) { + if (existing.type !== draft.type) { + throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`); + } + return existing.id; + } + } + const live = await tx.doc(LiveDoc, conversationId); + const busy = live.run !== undefined; + if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId); + const requestId = draft.requestId === undefined ? {} : { requestId: draft.requestId }; + // A boundary reads the table, so it is prepared before the first table write; a busy one needs none. + const boundary = busy ? undefined : await prepareBoundary(tx, conversationId); + if (boundary === undefined || boundary.inbox.items.length > 0) { + const { id } = await tx.createSubmission({ + conversationId, + ...requestId, + type: draft.type, + status: "queued", + }); + // Hosts may leave optional fields `undefined`; drafts take strict JSON. + const value = copyJson(draft.type === "write" ? draft.entry : draft.content, { + omitUndefinedProperties: true, + }); + const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items; + if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject }); + else { + const mode = draft.whenBusy === "steer" ? "steer" : "followUp"; + items.push({ id, mode, content: value as JsonRepresentation }); + } + if (boundary === undefined) return id; + const { users } = await applyBoundary(tx, boundary, "final", now); + if (users.length > 0) await startRun(tx, conversationId, live, users); + return id; + } + if (draft.type === "write") { + if (isStale(boundary, draft.entry)) { + const stale = { status: "unanswered", reason: "stale" } as const; + return (await tx.createSubmission({ conversationId, ...requestId, type: "write", ...stale })).id; + } + const entry = await tx.appendEntry(conversationId, draft.entry); + const write = { conversationId, ...requestId, type: "write", status: "done", entry: entry.id } as const; + return (await tx.createSubmission(write)).id; + } + const message = { role: "user", content: draft.content, timestamp: now } as const; + const entry = await tx.appendEntry(UserEntry, conversationId, { model: [message] }); + const input = { conversationId, ...requestId, type: "input", status: "placed", entry: entry.id } as const; + const { id } = await tx.createSubmission(input); + await startRun(tx, conversationId, live, [id]); + return id; +} diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index b6d31568d..18a6535aa 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -364,6 +364,27 @@ export type ConversationRetryPolicy = { maxAgentDelayMs?: number; }; +/** Automatic compaction thresholds (spec §8.7); manual compaction ignores `enabled`. */ +export type CompactionPolicy = { + /** Threshold and overflow compaction. */ + enabled: boolean; + /** Room kept free for the answer: generation blocks to compact above `contextWindow - reserveTokens`. */ + reserveTokens: number; + /** Approximate size of the recent context a summary keeps verbatim. */ + keepRecentTokens: number; + /** Background compaction starts `backgroundTokens` below the blocking threshold; `0` disables it. */ + backgroundTokens: number; +}; + +/** Why a compaction runs: `compact()`, a threshold in generation preparation, or a context overflow. */ +export type CompactionReason = "manual" | "threshold" | "overflow"; + +/** + * `entryId` of a blocking compaction's summary, or the `submissionId` of a conversation-owned compaction's summary + * write; both absent when nothing was compacted. + */ +export type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId }; + export type HarnessOptions = { /** pi-ai model access used by generation. */ readonly models: Models; @@ -413,6 +434,8 @@ export type ContextView = { readonly head: EntryRecord | undefined; /** Raw active entries: the head marker followed by non-head entries from its head through the tail. */ readonly entries: readonly EntryRecord[]; + /** Per entry of `entries`, its model messages after edits and excluded stop reasons, before tool result ordering. */ + readonly contributions: readonly (readonly Message[])[]; /** Model context for the next provider request. */ readonly messages: readonly Message[]; }; @@ -446,6 +469,10 @@ export interface Conversation { getFollowUpMode(context: Context): Promise; /** `undefined` removes the configured mode. */ setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise; + /** `DEFAULT_COMPACTION_POLICY` when unset. */ + getCompaction(context: Context): Promise; + /** `undefined` removes the configured policy. */ + setCompaction(policy: CompactionPolicy | undefined, context: Context): Promise; /** * Durably admit user input or a passive entry write. A busy conversation, or one with queued items, queues it in @@ -457,6 +484,11 @@ export interface Conversation { * Resolves after admission; while busy, it is placed at the next boundary. */ reset(handoff: string | undefined, context: Context): Promise; + /** + * Admit a manual compaction task and return its ID. It summarizes while the conversation keeps working and places its + * summary through a write submission: at once when idle, otherwise at the next boundary (spec §8.7). + */ + compact(instructions: string | undefined, context: Context): Promise>; /** Session commit whose `tx.createTask()` defaults to this conversation. */ commit(change: (tx: Tx) => T | Promise, context: Context): Promise; @@ -566,3 +598,23 @@ export interface ToolHooks { context: Context, ): HookResult; } + +/** Hooks of the built-in compaction task. */ +export interface CompactionHooks { + /** + * After range selection, before summarizing; the first decision wins. `entries` are the active entries the summary + * replaces, the head marker first, and `messages` their model context, the summarizer's source; `firstKept` is the + * first entry kept verbatim. + */ + beforeCompact( + compaction: { + readonly reason: CompactionReason; + readonly entries: readonly EntryRecord[]; + readonly messages: readonly Message[]; + readonly firstKept: EntryId; + readonly instructions?: string; + }, + api: HookApi, + context: Context, + ): HookResult<{ readonly decline: true } | { readonly summary: string }>; +} diff --git a/packages/durable/src/harness/usage.ts b/packages/durable/src/harness/usage.ts index 323bd7702..24cd9d721 100644 --- a/packages/durable/src/harness/usage.ts +++ b/packages/durable/src/harness/usage.ts @@ -3,9 +3,9 @@ import type { Usage } from "@earendil-works/pi-ai"; import { defineDoc } from "../documents.ts"; import type { ConversationId, Tx } from "../types.ts"; -/** Spend recorded by one conversation's own entries; the entries stay authoritative. */ +/** Ledger of one conversation's own spend: its entries, and compaction summarization attempts, which have none. */ export type UsageState = { - /** Assistant entries, keyed `provider/modelId`. */ + /** Assistant entries and summarization attempts, keyed `provider/modelId`. */ models: Record>; /** Tool results, keyed by tool name; their usage has no model identity. */ tools: Record>; @@ -21,7 +21,7 @@ export const UsageDoc = defineDoc({ checkpointWhen: () => true, }); -/** Add `usage` to one bucket of the conversation's `pi.usage`, in the commit that appends its entry. */ +/** Add `usage` to one bucket of the conversation's `pi.usage`, in the commit that records the response. */ export async function recordUsage( tx: Tx, conversationId: ConversationId, diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index 4577c7a8d..aafaf309a 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -1,6 +1,20 @@ export { defineDoc, defineDocFamily } from "./documents.ts"; -export { AssistantEntry, defineEntry, ResetEntry, SystemEntry, ToolResultEntry, UserEntry } from "./entries.ts"; +export { + AssistantEntry, + CompactionEntry, + defineEntry, + ResetEntry, + SystemEntry, + ToolResultEntry, + UserEntry, +} from "./entries.ts"; export { ConversationBusy, ReadAfterWrite, StorageRejected } from "./errors.ts"; +export { + type CompactionCheckpoint, + type CompactionInput, + CompactionTask, + type SummaryRequest, +} from "./harness/compaction.ts"; export { ConversationConfig, type ConversationConfigState } from "./harness/config.ts"; export { type AgentEvent, @@ -17,11 +31,15 @@ export { } from "./harness/generation.ts"; export { Harness } from "./harness/harness.ts"; export { InboxDoc, type InboxItem, type InboxState } from "./harness/inbox.ts"; -export { LiveDoc, type LiveState, type ToolSlot } from "./harness/live.ts"; +export { type CompactionStatus, LiveDoc, type LiveState, type ToolSlot } from "./harness/live.ts"; export { createRegistry } from "./harness/registry.ts"; export { ToolTask, type ToolTaskCheckpoint, type ToolTaskInput, type ToolTaskResult } from "./harness/tool.ts"; export type { AnyTask, + CompactionHooks, + CompactionPolicy, + CompactionReason, + CompactionResult, ContextView, Conversation, ConversationAbortOptions, diff --git a/packages/durable/test/examples/25-compaction.ts b/packages/durable/test/examples/25-compaction.ts new file mode 100644 index 000000000..b41e69f23 --- /dev/null +++ b/packages/durable/test/examples/25-compaction.ts @@ -0,0 +1,118 @@ +// Compaction: a long trip-planning chat whose older messages are summarized so the model context stays small. +// Run from packages/durable: +// node --conditions=source --experimental-strip-types test/examples/25-compaction.ts +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import type { AssistantMessage, Message, TranscriptContext } from "@earendil-works/pi-ai"; +import { createModels } from "@earendil-works/pi-ai/models"; +import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux"; +import { + CompactionEntry, + type Conversation, + createRegistry, + Harness, + LiveDoc, + MemoryStorage, +} from "../../src/index.ts"; + +const context = BACKGROUND_CONTEXT; + +// A fake model with a tiny 3000-token window. It answers chat messages, writes summaries when asked to summarize, +// and once rejects a request as too long, the way real providers report a context overflow. +let overflowOnce = false; +let summaries = 0; +// While set, the next chat answer waits for it, which keeps the conversation busy. +let hold: Promise | undefined; +async function respond(transcript: TranscriptContext): Promise { + const first = transcript.messages[0]; + if (first?.role === "system" && typeof first.content === "string" && first.content.includes("summarization")) { + summaries++; + return fauxAssistantMessage(`## Goal\nPlan a week in Lisbon (summary #${summaries}).`); + } + if (overflowOnce) { + overflowOnce = false; + return fauxAssistantMessage("", { stopReason: "error", errorMessage: "prompt is too long" }); + } + const held = hold; + hold = undefined; + await held; + const question = transcript.messages.findLast((message) => message.role === "user"); + return fauxAssistantMessage(`A detailed answer to "${text(question)}": ${"details ".repeat(200)}`); +} +const faux = fauxProvider({ models: [{ id: "tiny", contextWindow: 3000, maxTokens: 1000 }] }); +faux.setResponses(Array.from({ length: 100 }, () => respond)); +const models = createModels(); +models.setProvider(faux.provider); + +const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context); +const root = await harness.root(context); +await root.setModel({ provider: "faux", modelId: "tiny" }, context); +// Generation blocks to compact above 3000 - 1000 = 2000 tokens and starts a background compaction above 2000 - 800. +await root.setCompaction({ enabled: true, reserveTokens: 1000, keepRecentTokens: 400, backgroundTokens: 800 }, context); + +async function ask(question: string): Promise { + const submission = await root.submit({ type: "input", content: question }, context); + const record = await submission.wait(context); + // Let a background compaction started by this turn finish, so its summary shows below. + const running = (await harness.snapshot(LiveDoc, root.id, context))?.compactions ?? []; + for (const { taskId } of running) await harness.waitForTask(taskId, context); + await show(root, `after "${question}" (${record.status === "done" ? "answered" : record.reason})`); +} + +// 1. A long chat: once the context crosses the background threshold, a compaction runs while the chat goes on, and +// its summary lands at once when the conversation is idle, otherwise at the next turn boundary. +for (const question of [ + "Where should we stay?", + "What should we eat?", + "Which day trips?", + "Any museums?", + "Nightlife?", +]) { + await ask(question); +} + +// 2. A manual compaction while an answer is still being written. The summary is ready first, waits in the inbox, +// and is placed right after the answer. +let finishAnswer!: () => void; +hold = new Promise((resolve) => { + finishAnswer = resolve; +}); +const busy = await root.submit({ type: "input", content: "How do we get around?" }, context); +const manual = await root.compact("Keep the hotel shortlist", context); +const { state } = await harness.waitForTask(manual, context); +const placement = state.outcome.status === "completed" ? state.outcome.result.submissionId : undefined; +const summary = (await harness.submission(placement!, context))!; +console.log("\nmanual compaction finished; its summary is", (await summary.status(context)).status); +finishAnswer(); +await busy.wait(context); +console.log("after the answer, the summary is", (await summary.wait(context)).status); +await show(root, "after compact()"); + +// 3. The provider rejects a request as too long: generation compacts and retries it once. Background compaction is +// turned off so the summary below is the overflow one. +await root.setCompaction({ enabled: true, reserveTokens: 1000, keepRecentTokens: 400, backgroundTokens: 0 }, context); +await ask("What should we pack?"); +overflowOnce = true; +await ask("Summarize the plan for my partner"); + +await harness.close(context); + +/** Print the model context: one line per message, and the number of stored entries behind it. */ +async function show(conversation: Conversation, label: string): Promise { + const view = await conversation.context(context); + const stored = (await conversation.entries({}, 1000, undefined, context)).items.length; + console.log(`\n${label}: ${view.messages.length} messages in context, ${stored} entries stored`); + const reason = CompactionEntry.is(view.head) ? view.head.data.reason : undefined; + if (reason !== undefined) console.log(` (${reason} compaction summary first)`); + for (const message of view.messages) { + console.log( + ` ${message.role.padEnd(9)} ${message.role === "system" ? "(system prompt)" : text(message).slice(0, 70)}`, + ); + } +} + +function text(message: Message | undefined): string { + if (message === undefined || message.role === "system") return ""; + if (typeof message.content === "string") return message.content; + const block = message.content.find((content) => content.type === "text"); + return block?.type === "text" ? block.text.replaceAll("\n", " ") : ""; +} diff --git a/packages/durable/test/harness-compaction.test.ts b/packages/durable/test/harness-compaction.test.ts new file mode 100644 index 000000000..886dd0fb9 --- /dev/null +++ b/packages/durable/test/harness-compaction.test.ts @@ -0,0 +1,2253 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type AssistantMessage, + fauxAssistantMessage, + fauxToolCall, + type Message, + type SimpleStreamOptions, + type TranscriptContext, + Type, + type UserMessage, +} from "@earendil-works/pi-ai"; +import { + type AgentEvent, + type CompactionPolicy, + type CompactionResult, + CompactionTask, + type ContextView, + type Conversation, + defineTask, + type EntryRecord, + type Harness, + LiveDoc, + MemoryStorage, + StorageRejected, + type TaskId, + UsageDoc, + watchEvents, +} from "@earendil-works/pi-durable"; +import { afterEach, describe, expect, it } from "vitest"; +import { selectCut, serializeConversation } from "../src/harness/compaction.ts"; +import { orderToolResults } from "../src/harness/context.ts"; +import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; +import { allEntries, type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts"; +import { ControlledStorage, context } from "./session-support.ts"; +import { aborted, type Deferred, deferred } from "./task-support.ts"; + +const directories = new Set(); + +afterEach(async () => { + for (const directory of directories) await rm(directory, { recursive: true, force: true }); + directories.clear(); +}); + +async function sqlitePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), "pi-durable-compaction-")); + directories.add(directory); + return join(directory, "session.sqlite"); +} + +/** Text of about `tokens` estimated tokens, starting with `label`. */ +function text(label: string, tokens: number): string { + return `${label} ${"x".repeat(Math.max(0, tokens * 4 - label.length - 1))}`; +} + +type Request = { + readonly messages: readonly Message[]; + readonly options: SimpleStreamOptions | undefined; + readonly model: string; +}; +type Step = AssistantMessage | ((request: Request) => AssistantMessage | Promise); + +/** Scripted faux model that answers agent requests and summarization requests from separate queues. */ +type Script = { + readonly agent: Step[]; + readonly summaries: Step[]; + readonly agentRequests: Request[]; + readonly summaryRequests: Request[]; +}; + +function isSummaryRequest(messages: readonly Message[]): boolean { + const first = messages[0]; + return ( + first?.role === "system" && + typeof first.content === "string" && + first.content.startsWith("You are a context summarization assistant") + ); +} + +function script(setup: ChatSetup): Script { + const result: Script = { agent: [], summaries: [], agentRequests: [], summaryRequests: [] }; + setup.faux.setResponses( + Array.from( + { length: 500 }, + () => + async ( + transcript: TranscriptContext, + options?: SimpleStreamOptions, + _state?: unknown, + model?: { id: string }, + ) => { + const request = { messages: [...transcript.messages], options, model: model!.id }; + const summary = isSummaryRequest(request.messages); + (summary ? result.summaryRequests : result.agentRequests).push(request); + const step = (summary ? result.summaries : result.agent).shift(); + if (step === undefined) throw new Error(`No scripted ${summary ? "summary" : "agent"} response`); + return typeof step === "function" ? step(request) : step; + }, + ), + ); + return result; +} + +/** A step that waits for `gate`, or rejects when the request is cancelled. */ +function gated(gate: Deferred, message: AssistantMessage, reached?: Deferred): Step { + return async ({ options }) => { + reached?.resolve(); + await Promise.race([gate.promise, aborted(options!.signal!)]); + return message; + }; +} + +const answer = (content: string) => fauxAssistantMessage(content); +const failure = (errorMessage: string) => fauxAssistantMessage("", { stopReason: "error", errorMessage }); + +const OVERFLOW = "prompt is too long: 250000 tokens > 200000 maximum"; + +/** Small thresholds: no automatic compaction unless a test enables it. */ +const MANUAL: CompactionPolicy = { enabled: false, reserveTokens: 1000, keepRecentTokens: 150, backgroundTokens: 0 }; + +type Chat = { + readonly harness: Harness; + readonly root: Conversation; + readonly setup: ChatSetup; + readonly faux: Script; +}; + +async function open( + options: { + readonly policy?: CompactionPolicy; + readonly contextWindow?: number; + readonly storage?: MemoryStorage; + } = {}, + setup = chatSetup({ models: [{ id: "faux-1", contextWindow: options.contextWindow ?? 100_000, maxTokens: 900 }] }), + faux = script(setup), +): Promise { + setup.registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false }); + const { harness, root } = await openChat(options.storage ?? new MemoryStorage(), setup); + await root.setCompaction(options.policy ?? MANUAL, context); + await root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 1 }, context); + harness.resume(); + return { harness, root, setup, faux }; +} + +/** Run one turn: `user` answered by `reply`. */ +async function turn(chat: Chat, user: string, reply: string): Promise { + chat.faux.agent.push(answer(reply)); + const submission = await chat.root.submit({ type: "input", content: user }, context); + expect((await submission.wait(context)).status).toBe("done"); +} + +/** Three turns of about 100-token messages. */ +async function history(chat: Chat): Promise { + await turn(chat, text("u1", 100), text("a1", 100)); + await turn(chat, text("u2", 100), text("a2", 100)); + await turn(chat, text("u3", 100), text("a3", 100)); +} + +const summary = (content = "SUMMARY") => answer(content); + +async function result(chat: Chat, id: TaskId) { + const record = await chat.harness.waitForTask(id, context); + return record.state.outcome; +} + +async function kinds(conversation: Conversation): Promise { + return (await allEntries(conversation)).map((entry) => entry.kind); +} + +async function live(chat: Chat) { + return (await chat.harness.snapshot(LiveDoc, chat.root.id, context)) ?? {}; +} + +function userText(message: Message | undefined): string { + return textOf(message) ?? ""; +} + +// ─── Range selection ────────────────────────────────────────────────────── + +let nextId = 1; +function entry(kind: string, model: Message[], head?: number): EntryRecord { + return { + id: nextId++, + conversationId: 1, + kind, + model, + ...(head === undefined ? {} : { head }), + } as unknown as EntryRecord; +} +const user = (content: string): UserMessage => ({ role: "user", content, timestamp: 0 }); +const assistant = (content: string, calls: string[] = [], stopReason: AssistantMessage["stopReason"] = "stop") => + ({ + ...fauxAssistantMessage([ + { type: "text", text: content }, + ...calls.map((id) => fauxToolCall("read", {}, { id })), + ]), + stopReason, + }) as AssistantMessage; +const toolResult = (callId: string, content: string): Message => ({ + role: "toolResult", + toolCallId: callId, + toolName: "read", + content: [{ type: "text", text: content }], + isError: false, + timestamp: 0, +}); + +/** A view over `entries` whose contributions are their models, with excluded assistants removed. */ +function view(entries: EntryRecord[], head?: EntryRecord): ContextView { + const all = head === undefined ? entries : [head, ...entries]; + const contributions = all.map((record) => + (record.model ?? []).filter( + (message) => message.role !== "assistant" || !["error", "aborted", "deferred"].includes(message.stopReason), + ), + ); + return { head, entries: all, contributions, messages: orderToolResults(contributions.flat()) }; +} + +describe("range selection", () => { + it("keeps about keepRecentTokens and cuts at the first candidate at or after the budget (spec §8.7 example)", () => { + const entries = [ + entry("pi.user", [user(text("1", 10))]), + entry("pi.assistant", [assistant("2", ["c1"])]), + entry("pi.tool-result", [toolResult("c1", text("3", 3000))]), + entry("pi.assistant", [assistant(text("4", 10))]), + entry("pi.user", [user(text("5", 10))]), + entry("pi.assistant", [assistant(text("6", 10))]), + ]; + expect(selectCut(view(entries), 2000)).toBe(3); + }); + + it("cuts at a user entry", () => { + const entries = [ + entry("pi.user", [user(text("u1", 100))]), + entry("pi.assistant", [assistant(text("a1", 100))]), + entry("pi.user", [user(text("u2", 100))]), + entry("pi.assistant", [assistant(text("a2", 100))]), + ]; + expect(selectCut(view(entries), 150)).toBe(2); + }); + + it("cuts at an assistant in the middle of one long run and never at a tool result", () => { + const entries = [entry("pi.user", [user("do it")])]; + for (let index = 0; index < 5; index++) { + entries.push(entry("pi.assistant", [assistant(`step ${index}`, [`c${index}`])])); + entries.push(entry("pi.tool-result", [toolResult(`c${index}`, text(`r${index}`, 100))])); + } + const cut = selectCut(view(entries), 150)!; + // The budget is reached at the fourth result; the cut is the last call, whose result it keeps. + expect(entries[cut]!.kind).toBe("pi.assistant"); + expect(cut).toBe(9); + }); + + it("keeps a huge last tool result together with its assistant", () => { + const entries = [ + entry("pi.user", [user("u")]), + entry("pi.assistant", [assistant("a", ["c"])]), + entry("pi.tool-result", [toolResult("c", text("big", 5000))]), + ]; + expect(selectCut(view(entries), 100)).toBe(1); + }); + + it("never cuts at a system entry or an excluded error or aborted answer", () => { + const entries = [ + entry("pi.user", [user(text("u1", 100))]), + entry("pi.assistant", [assistant(text("a1", 100))]), + entry("pi.system", [{ role: "system", content: "", sections: { s: text("s", 100) }, timestamp: 0 }]), + entry("pi.assistant", [assistant(text("err", 100), [], "error")]), + entry("pi.assistant", [assistant(text("stopped", 100), [], "aborted")]), + entry("pi.assistant", [assistant(text("a2", 100))]), + ]; + // The walk reaches 150 at the system entry; the excluded answers after it contribute nothing. + expect(selectCut(view(entries), 150)).toBe(5); + }); + + it("follows edited contributions: an omitted entry adds nothing and is no candidate", () => { + const entries = [ + entry("pi.user", [user(text("u1", 100))]), + entry("pi.assistant", [assistant(text("a1", 100))]), + entry("pi.user", [user(text("u2", 100))]), + entry("pi.assistant", [assistant(text("a2", 100))]), + ]; + const plain = view(entries); + expect(selectCut(plain, 150)).toBe(2); + const contributions = plain.contributions.map((messages, index) => (index === 2 ? [] : messages)); + const omitted = { ...plain, contributions, messages: orderToolResults(contributions.flat()) }; + expect(selectCut(omitted, 150)).toBe(1); + }); + + it("does not cut at a user entry that a result of the preceding call still follows", () => { + const entries = [ + entry("pi.user", [user(text("u1", 100))]), + entry("pi.assistant", [assistant("a", ["c"])]), + entry("pi.user", [user(text("steer", 100))]), + entry("pi.tool-result", [toolResult("c", text("r", 100))]), + entry("pi.assistant", [assistant(text("a2", 100))]), + ]; + // The budget is reached at the steer; its result follows it, so the cut moves to the next assistant. + expect(selectCut(view(entries), 250)).toBe(4); + }); + + it("finds nothing when the budget is never reached or only the marker precedes the cut", () => { + const small = [entry("pi.user", [user("hi")]), entry("pi.assistant", [assistant("hello")])]; + expect(selectCut(view(small), 150)).toBeUndefined(); + const marker = entry("pi.compaction", [user("summary")], 0); + // The budget is reached at the only entry after the marker, so the marker alone would be summarized. + expect(selectCut(view([entry("pi.user", [user(text("u", 200))])], marker), 150)).toBeUndefined(); + }); + + it("summarizes an earlier summary marker first", () => { + const marker = entry("pi.compaction", [user("EARLIER")], 0); + const kept = [ + entry("pi.user", [user(text("u1", 100))]), + entry("pi.assistant", [assistant(text("a1", 100))]), + entry("pi.user", [user(text("u2", 100))]), + entry("pi.assistant", [assistant(text("a2", 100))]), + ]; + const selected = view(kept, marker); + expect(selectCut(selected, 150)).toBe(3); + expect(serializeConversation(selected.contributions.slice(0, 3).flat())).toMatch(/^\[User\]: EARLIER/); + }); +}); + +describe("serialization", () => { + it("writes a transcript, truncates tool results, and omits system messages", () => { + const call = fauxToolCall("read", { path: "a.ts" }, { id: "c" }); + const messages: Message[] = [ + { role: "system", content: "", sections: { s: "hidden" }, timestamp: 0 }, + user("hello"), + { + ...fauxAssistantMessage([{ type: "thinking", thinking: "hmm" }, { type: "text", text: "sure" }, call]), + }, + toolResult("c", "y".repeat(2500)), + ]; + const serialized = serializeConversation(messages); + expect(serialized).not.toContain("hidden"); + expect(serialized).toContain("[User]: hello"); + expect(serialized).toContain("[Assistant thinking]: hmm"); + expect(serialized).toContain("[Assistant]: sure"); + expect(serialized).toContain('[Assistant tool calls]: read(path="a.ts")'); + expect(serialized).toContain(`[Tool result]: ${"y".repeat(2000)}\n\n[... 500 more characters truncated]`); + }); +}); + +// ─── Manual compaction ──────────────────────────────────────────────────── + +describe("manual compaction", () => { + it("places the summary at once when idle and keeps raw history", async () => { + const chat = await open(); + await history(chat); + const before = await allEntries(chat.root); + const usageBefore = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; + chat.faux.summaries.push(summary()); + + const id = await chat.root.compact("focus on files", context); + const outcome = await result(chat, id); + expect(outcome.status).toBe("completed"); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId : undefined; + const placed = await (await chat.harness.submission(submissionId!, context))!.wait(context); + expect(placed.status).toBe("done"); + + // Raw history is unchanged; one summary entry heads the first kept entry, u3. + const after = await allEntries(chat.root); + expect(after.slice(0, before.length)).toEqual(before); + const marker = after.at(-1)!; + const u3 = before.find((record) => userText(record.model?.[0]).startsWith("u3"))!; + expect(marker).toMatchObject({ kind: "pi.compaction", head: u3.id, data: { reason: "manual" } }); + expect(placed.status === "done" && placed.entry).toBe(marker.id); + expect(userText(marker.model?.[0])).toBe( + "The conversation history before this point was compacted into the following summary:\n\n\nSUMMARY\n", + ); + + // The model context is the summary followed by the kept entries. + const view = await chat.root.context(context); + expect(view.messages.map(userText)).toEqual([userText(marker.model?.[0]), text("u3", 100), text("a3", 100)]); + + // The summarizer saw the serialized prefix, the prompt, and the instructions, without tools or caching. + const request = chat.faux.summaryRequests[0]!; + expect(request.messages).toHaveLength(2); + const prompt = userText(request.messages[1]); + expect(prompt).toMatch(/^\n\[User\]: u1 /); + expect(prompt).toContain("[Assistant]: a2 "); + expect(prompt).not.toContain("u3 "); + expect(prompt).toContain("## Goal"); + expect(prompt.endsWith("\n\nAdditional focus: focus on files")).toBe(true); + expect(request.options).toMatchObject({ cacheRetention: "none", maxTokens: 800 }); + expect(request.options?.deferred).toBeUndefined(); + + // The summarizer's spend is in the ledger, and nothing counts it again later. + const usage = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; + expect(usage.input).toBeGreaterThan(usageBefore.input); + await turn(chat, "next", "done"); + const agent = chat.faux.agentRequests.at(-1)!.messages; + // The next request: the summary, the kept turn, the new input, then one complete system baseline. + expect(agent.map((message) => message.role)).toEqual(["user", "user", "assistant", "user", "system"]); + expect(agent[4]).toMatchObject({ sections: { preamble: "You are helpful." } }); + await chat.harness.close(context); + }); + + it("keeps working while busy and places the summary at the next final boundary", async () => { + const chat = await open(); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.agent.push(gated(gate, answer("late answer"), reached)); + const input = await chat.root.submit({ type: "input", content: "busy" }, context); + await reached.promise; + chat.faux.summaries.push(summary()); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + expect((await (await chat.harness.submission(submissionId!, context))!.status(context)).status).toBe("queued"); + gate.resolve(); + expect((await input.wait(context)).status).toBe("done"); + const placed = await (await chat.harness.submission(submissionId!, context))!.wait(context); + expect(placed.status).toBe("done"); + // The summary follows the answer; the kept range still includes the busy turn. + expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.user", "pi.assistant", "pi.compaction"]); + await chat.harness.close(context); + }); + + it("places a queued summary at postTools and the run continues in the compacted context", async () => { + const chat = await open(); + chat.setup.registry.tools.add({ + name: "wait", + description: "wait", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "waited" }] }), + }); + await chat.root.setActiveTools(["wait"], context); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.agent.push( + gated(gate, fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }), reached), + ); + chat.faux.agent.push(answer("after tools")); + const input = await chat.root.submit({ type: "input", content: "use a tool" }, context); + await reached.promise; + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + gate.resolve(); + expect((await input.wait(context)).status).toBe("done"); + // The continuation request starts with the summary. + const continuation = chat.faux.agentRequests.at(-1)!.messages; + expect(userText(continuation[0])).toContain("\nSUMMARY\n"); + expect((await kinds(chat.root)).slice(-4)).toEqual([ + "pi.tool-result", + "pi.compaction", + "pi.system", + "pi.assistant", + ]); + await chat.harness.close(context); + }); + + it("runs follow-ups left by a failed run after placing the summary", async () => { + const chat = await open(); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.agent.push(gated(gate, failure("bad request"), reached)); + const failed = await chat.root.submit({ type: "input", content: "fails" }, context); + await reached.promise; + const followUp = await chat.root.submit({ type: "input", content: "follow-up" }, context); + gate.resolve(); + expect((await failed.wait(context)).status).toBe("unanswered"); + expect((await followUp.status(context)).status).toBe("queued"); + + chat.faux.summaries.push(summary()); + chat.faux.agent.push(answer("followed")); + await result(chat, await chat.root.compact(undefined, context)); + expect((await followUp.wait(context)).status).toBe("done"); + const request = chat.faux.agentRequests.at(-1)!.messages; + expect(userText(request[0])).toContain("SUMMARY"); + expect(request.some((message) => userText(message) === "follow-up")).toBe(true); + await chat.harness.close(context); + }); + + it("settles stale when a reset lands while it summarizes", async () => { + const chat = await open(); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + await chat.root.reset(undefined, context); + gate.resolve(); + const outcome = await result(chat, id); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + expect(await (await chat.harness.submission(submissionId!, context))!.status(context)).toMatchObject({ + status: "unanswered", + reason: "stale", + }); + expect((await kinds(chat.root)).at(-1)).toBe("pi.reset"); + await chat.harness.close(context); + }); + + it("does not make the conversation busy: a submission during summarization starts its run at once", async () => { + const chat = await open(); + await history(chat); + const summaryGate = deferred(); + const summaryReached = deferred(); + chat.faux.summaries.push(gated(summaryGate, summary(), summaryReached)); + const id = await chat.root.compact(undefined, context); + await summaryReached.promise; + const answerGate = deferred(); + const answerReached = deferred(); + chat.faux.agent.push(gated(answerGate, answer("a4"), answerReached)); + const input = await chat.root.submit({ type: "input", content: "u4" }, context); + // Placed and answered immediately with the uncompacted context, not queued behind the compaction. + expect((await input.status(context)).status).toBe("placed"); + await answerReached.promise; + expect(userText(chat.faux.agentRequests.at(-1)!.messages[0])).toBe(text("u1", 100)); + // The summary is ready while the run is busy, so it queues and lands after the answer. + summaryGate.resolve(); + const outcome = await result(chat, id); + const submission = (await chat.harness.submission( + (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, + context, + ))!; + expect((await submission.status(context)).status).toBe("queued"); + answerGate.resolve(); + expect((await input.wait(context)).status).toBe("done"); + expect((await submission.wait(context)).status).toBe("done"); + expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.user", "pi.assistant", "pi.compaction"]); + await chat.harness.close(context); + }); + + it("counts the spend of a summary that ends stale, and writes no entry for it", async () => { + const chat = await open(); + await history(chat); + const input = async () => + (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!.input; + const before = await input(); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + await chat.root.reset(undefined, context); + gate.resolve(); + const outcome = await result(chat, id); + const submission = (await chat.harness.submission( + (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, + context, + ))!; + expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "stale" }); + expect(await input()).toBeGreaterThan(before); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + await chat.harness.close(context); + }); + + it("lets the compaction that cuts furthest win, whatever finishes first", async () => { + const chat = await open(); + await history(chat); + const first = deferred(); + const firstReached = deferred(); + chat.faux.summaries.push(gated(first, summary("FIRST"), firstReached)); + const early = await chat.root.compact(undefined, context); + await firstReached.promise; + await turn(chat, text("u4", 100), text("a4", 100)); + chat.faux.summaries.push(summary("SECOND")); + const later = await result(chat, await chat.root.compact(undefined, context)); + expect(later.status).toBe("completed"); + first.resolve(); + const outcome = await result(chat, early); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + // The early compaction cut at u3, before the later cut at u4. + expect(await (await chat.harness.submission(submissionId!, context))!.status(context)).toMatchObject({ + status: "unanswered", + reason: "stale", + }); + expect(userText((await chat.root.context(context)).messages[0])).toContain("SECOND"); + await chat.harness.close(context); + }); + + it("places an older-selected summary that cuts later than the newer one", async () => { + const chat = await open(); + await history(chat); + // A: small budget, late cut; selected first, finishes last. + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary("A"), reached)); + const a = await chat.root.compact(undefined, context); + await reached.promise; + // B: larger budget, earlier cut; placed first. + await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: 350 }, context); + chat.faux.summaries.push(summary("B")); + await result(chat, await chat.root.compact(undefined, context)); + expect(userText((await chat.root.context(context)).messages[0])).toContain("B"); + gate.resolve(); + const outcome = await result(chat, a); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + expect((await (await chat.harness.submission(submissionId!, context))!.status(context)).status).toBe("done"); + const messages = (await chat.root.context(context)).messages; + expect(userText(messages[0])).toContain("\nA\n"); + expect(messages.slice(1).map(userText)).toEqual([text("u3", 100), text("a3", 100)]); + await chat.harness.close(context); + }); + + for (const [order, keeps, stale] of [ + ["before", [150, 350], true], + ["at", [150, 150], false], + ["after", [350, 150], false], + ] as const) { + it(`places two queued summaries in one boundary when the second cuts ${order} the first`, async () => { + const chat = await open(); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.agent.push(gated(gate, answer("done"), reached)); + const input = await chat.root.submit({ type: "input", content: "busy" }, context); + await reached.promise; + const submissions = []; + for (const [index, keep] of keeps.entries()) { + await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: keep }, context); + chat.faux.summaries.push(summary(`S${index}`)); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + submissions.push(outcome.status === "completed" ? outcome.result.submissionId! : undefined); + } + gate.resolve(); + await input.wait(context); + const statuses = []; + for (const id of submissions) + statuses.push((await (await chat.harness.submission(id!, context))!.wait(context)).status); + expect(statuses).toEqual(["done", stale ? "unanswered" : "done"]); + await chat.harness.close(context); + }); + } + + it("is aborted by Conversation.abort(); an already queued summary survives it", async () => { + const chat = await open(); + await history(chat); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + expect((await live(chat)).compactions).toHaveLength(1); + await chat.root.abort(context); + expect((await result(chat, id)).status).toBe("aborted"); + expect((await live(chat)).compactions).toBeUndefined(); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + + // Queued while busy, then Esc: the queued write stays and lands with the next run's boundary. + const gate = deferred(); + const busy = deferred(); + chat.faux.agent.push(gated(gate, answer("never"), busy)); + await chat.root.submit({ type: "input", content: "busy" }, context); + await busy.promise; + chat.faux.summaries.push(summary()); + const queued = await result(chat, await chat.root.compact(undefined, context)); + await chat.root.abort(context); + const submissionId = queued.status === "completed" ? queued.result.submissionId! : undefined; + const submission = (await chat.harness.submission(submissionId!, context))!; + expect((await submission.status(context)).status).toBe("queued"); + expect(await submission.abort(context)).toBe("aborted"); + await turn(chat, "next", "ok"); + expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + await chat.harness.close(context); + }); + + it("is ordinary work: idle waits include it", async () => { + const chat = await open(); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + let idle = false; + const wait = chat.root.waitForIdle(context).then(() => { + idle = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(idle).toBe(false); + gate.resolve(); + await wait; + expect((await chat.harness.getTask(id, context))?.state.status).toBe("terminal"); + await chat.harness.close(context); + }); + + it("enables scheduling right after open", async () => { + const setup = chatSetup(); + const faux = script(setup); + const { harness, root } = await openChat(new MemoryStorage(), setup); + await root.setCompaction({ ...MANUAL, keepRecentTokens: 10 }, context); + const id = await root.compact(undefined, context); + // getTask() only reads, so progress here comes from compact() itself. + await waitFor(async () => (await harness.getTask(id, context))?.state.status === "terminal"); + expect((await harness.getTask(id, context))?.state).toMatchObject({ + outcome: { status: "completed", result: {} }, + }); + expect(faux.summaryRequests).toHaveLength(0); + await harness.close(context); + }); +}); + +describe("compaction outcomes", () => { + it("completes without a summary when there is nothing to compact", async () => { + const chat = await open(); + await turn(chat, "hi", "hello"); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + expect(outcome).toEqual({ status: "completed", result: {} }); + expect(chat.faux.summaryRequests).toHaveLength(0); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + }); + + it("asks beforeCompact: the first decision wins, a throw is reported and skipped", async () => { + const chat = await open(); + const seen: unknown[] = []; + chat.setup.registry.hooks.add(CompactionTask, { + beforeCompact: () => { + throw new Error("hook broke"); + }, + }); + chat.setup.registry.hooks.add(CompactionTask, { + beforeCompact: (compaction) => { + seen.push(compaction); + return { summary: "FROM HOOK" }; + }, + }); + chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }); + await history(chat); + const outcome = await result(chat, await chat.root.compact("why", context)); + expect(outcome.status).toBe("completed"); + expect(chat.faux.summaryRequests).toHaveLength(0); + expect(userText((await chat.root.context(context)).messages[0])).toContain("\nFROM HOOK\n"); + expect(chat.setup.reports).toEqual([expect.objectContaining({ message: "hook broke" })]); + const compaction = seen[0] as { entries: EntryRecord[]; messages: Message[]; firstKept: number }; + expect(compaction).toMatchObject({ reason: "manual", instructions: "why" }); + expect(compaction.entries.map((record) => record.kind)).toEqual([ + "pi.user", + "pi.system", + "pi.assistant", + "pi.user", + "pi.assistant", + ]); + expect(compaction.messages.filter((message) => message.role !== "system").map(userText)).toEqual([ + text("u1", 100), + text("a1", 100), + text("u2", 100), + text("a2", 100), + ]); + await chat.harness.close(context); + }); + + it("completes without a summary when a hook declines", async () => { + const chat = await open(); + chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }); + await history(chat); + expect(await result(chat, await chat.root.compact(undefined, context))).toEqual({ + status: "completed", + result: {}, + }); + expect(chat.faux.summaryRequests).toHaveLength(0); + await chat.harness.close(context); + }); + + it("fails with no_model without a configured model", async () => { + const chat = await open(); + await history(chat); + await chat.root.setModel(undefined, context); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + expect(outcome).toMatchObject({ status: "failed", error: { detail: { reason: "no_model" } } }); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + }); + + /** Input tokens a compaction added to the ledger. */ + async function compactionInput(chat: Chat, run: () => Promise): Promise { + const input = async () => + (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!.input; + const before = await input(); + await run(); + return (await input()) - before; + } + + it("retries a retryable error with the pinned request and counts every attempt once", async () => { + const single = await open(); + await history(single); + await single.root.setThinkingLevel("high", context); + single.faux.summaries.push(summary()); + const once = await compactionInput(single, async () => + result(single, await single.root.compact(undefined, context)), + ); + expect(once).toBeGreaterThan(0); + await single.harness.close(context); + + const chat = await open(); + await history(chat); + await chat.root.setThinkingLevel("high", context); + await chat.root.setStreamOptions({ timeoutMs: 1234, deferred: true }, context); + chat.faux.summaries.push(async () => { + // Changed during the attempt: the retry still uses the pinned request. + await chat.root.setThinkingLevel("low", context); + await chat.root.setStreamOptions({ timeoutMs: 1 }, context); + return failure("overloaded"); + }); + chat.faux.summaries.push(summary()); + const twice = await compactionInput(chat, async () => { + expect((await result(chat, await chat.root.compact(undefined, context))).status).toBe("completed"); + }); + expect(chat.faux.summaryRequests).toHaveLength(2); + expect(twice).toBe(2 * once); + for (const request of chat.faux.summaryRequests) { + expect(request.options).toMatchObject({ reasoning: "high", timeoutMs: 1234, cacheRetention: "none" }); + expect(request.options?.deferred).toBeUndefined(); + } + await chat.harness.close(context); + }); + + it("adds no usage when a hook declines or supplies the summary", async () => { + for (const decision of [{ decline: true } as const, { summary: "HOOK" }]) { + const chat = await open(); + chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => decision }); + await history(chat); + expect( + await compactionInput(chat, async () => result(chat, await chat.root.compact(undefined, context))), + ).toBe(0); + await chat.harness.close(context); + } + }); + + it("caps maxTokens at the model's output limit and sends no tools", async () => { + const chat = await open(); + chat.setup.registry.tools.add({ + name: "read", + description: "read", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }); + await chat.root.setActiveTools(["read"], context); + await history(chat); + await chat.root.setCompaction({ ...MANUAL, reserveTokens: 2000 }, context); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + const request = chat.faux.summaryRequests[0]!; + // 0.8 * 2000 = 1600, above the model's 900. + expect(request.options?.maxTokens).toBe(900); + expect(request.messages).toHaveLength(2); + expect(request.messages.some((message) => message.role === "system" && message.toolsAdded !== undefined)).toBe( + false, + ); + await chat.harness.close(context); + }); + + for (const [name, response, message] of [ + ["retries run out", failure("overloaded"), "Summarization failed: overloaded"], + ["a non-retryable error", failure("bad request"), "Summarization failed: bad request"], + [ + "a length stop", + fauxAssistantMessage("partial", { stopReason: "length" }), + "Summarization hit the token limit; the summary is incomplete", + ], + [ + "a tool call", + fauxAssistantMessage([fauxToolCall("read", {})], { stopReason: "stop" }), + "Summarization attempted to call a tool", + ], + ["empty text", answer(" "), "Summarization produced no text"], + ] as const) { + it(`fails with model_error on ${name}`, async () => { + const chat = await open(); + await history(chat); + for (let index = 0; index < 3; index++) chat.faux.summaries.push(response); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + expect(outcome).toMatchObject({ status: "failed", error: { message, detail: { reason: "model_error" } } }); + // The retry policy allows two retries after the first attempt. + expect(chat.faux.summaryRequests).toHaveLength(name === "retries run out" ? 3 : 1); + expect((await live(chat)).compactions).toBeUndefined(); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + await chat.harness.close(context); + }); + } +}); + +// ─── Automatic compaction ───────────────────────────────────────────────── + +/** Background threshold at 500 and blocking threshold at 1500 tokens of a 2000-token window. */ +const BACKGROUND: CompactionPolicy = { + enabled: true, + reserveTokens: 500, + keepRecentTokens: 150, + backgroundTokens: 1000, +}; +/** Blocking threshold at 700 tokens of a 1000-token window, no background compaction. */ +const BLOCKING: CompactionPolicy = { enabled: true, reserveTokens: 300, keepRecentTokens: 150, backgroundTokens: 0 }; + +async function compactionTasks(chat: Chat) { + const inspection = await chat.harness.inspect(context); + return inspection.tasks.map((task) => task.record).filter((record) => record.kind === "pi.compaction"); +} + +describe("background threshold compaction", () => { + it("starts above the background threshold without blocking the run; idle waits and Esc ignore it", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(BACKGROUND, context); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + await turn(chat, text("u4", 100), text("a4", 100)); + await reached.promise; + const [task] = await compactionTasks(chat); + expect(task).toMatchObject({ background: true, input: { reason: "threshold" } }); + expect(task!.owner).toBeUndefined(); + expect((await live(chat)).compactions).toEqual([ + { taskId: task!.id, reason: "threshold", blocking: false, attempt: 1 }, + ]); + // Background work: neither conversation idle nor Esc waits for or stops it. + await chat.root.waitForIdle(context); + await chat.root.abort(context); + expect((await chat.harness.getTask(task!.id, context))?.state.status).toBe("running"); + gate.resolve(); + const outcome = await result(chat, task!.id as TaskId); + expect(outcome.status).toBe("completed"); + expect((await kinds(chat.root)).at(-1)).toBe("pi.compaction"); + expect(userText((await chat.root.context(context)).messages[0])).toContain("SUMMARY"); + await chat.harness.close(context); + }); + + for (const [name, policy] of [ + ["disabled", { ...BACKGROUND, enabled: false }], + ["backgroundTokens is 0", { ...BACKGROUND, backgroundTokens: 0 }], + ["there is no cut", { ...BACKGROUND, keepRecentTokens: 100_000 }], + ] as const) { + it(`does not start when ${name}`, async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(policy, context); + await turn(chat, text("u4", 100), text("a4", 100)); + expect(await compactionTasks(chat)).toEqual([]); + expect(chat.faux.summaryRequests).toHaveLength(0); + await chat.harness.close(context); + }); + } + + it("does not start while another compaction is listed", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + const manual = await chat.root.compact(undefined, context); + await reached.promise; + await chat.root.setCompaction(BACKGROUND, context); + await turn(chat, text("u4", 100), text("a4", 100)); + expect((await compactionTasks(chat)).map((task) => task.id)).toEqual([manual]); + await chat.harness.abortTask(manual, context); + await chat.harness.close(context); + }); + + it("stops through abortTask() and Conversation.abort() with background", async () => { + for (const stop of ["task", "conversation"] as const) { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(BACKGROUND, context); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + await turn(chat, text("u4", 100), text("a4", 100)); + await reached.promise; + const [task] = await compactionTasks(chat); + if (stop === "task") await chat.harness.abortTask(task!.id, context); + else await chat.root.abort(context, { background: true }); + expect((await result(chat, task!.id as TaskId)).status).toBe("aborted"); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + } + }); +}); + +describe("blocking threshold compaction", () => { + it("waits for its compaction, which appends the summary before the request", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + // A new section: preparation has a system entry to append, but must not append it before the wait. + chat.setup.registry.systemPrompt.section("extra", () => "EXTRA"); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + chat.faux.agent.push(answer("a4")); + const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); + await reached.promise; + const [child] = await compactionTasks(chat); + const generation = (await live(chat)).run!.taskId; + expect(child).toMatchObject({ owner: generation, background: false, input: { reason: "threshold" } }); + expect((await chat.harness.getTask(generation, context))?.state).toMatchObject({ + status: "waiting", + on: [child!.id], + checkpoint: { phase: "prepare", attempt: 1, compacted: child!.id }, + }); + expect((await live(chat)).compactions).toEqual([ + { taskId: child!.id, reason: "threshold", blocking: true, attempt: 1 }, + ]); + // Nothing was appended before the wait. + expect((await kinds(chat.root)).at(-1)).toBe("pi.user"); + gate.resolve(); + expect((await input.wait(context)).status).toBe("done"); + expect((await result(chat, child!.id as TaskId)).status).toBe("completed"); + expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.compaction", "pi.system", "pi.assistant"]); + const request = chat.faux.agentRequests.at(-1)!.messages; + expect(userText(request[0])).toContain("SUMMARY"); + const systems = request.filter((message) => message.role === "system"); + expect(systems).toHaveLength(1); + expect(systems[0]).toMatchObject({ + sections: { preamble: "You are helpful.", extra: "\nEXTRA\n" }, + }); + await chat.harness.close(context); + }); + + it("sends the request once, without a second compaction, when the kept part is still above the threshold", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction({ ...BLOCKING, keepRecentTokens: 700 }, context); + chat.faux.summaries.push(summary()); + await turn(chat, text("u4", 400), "a4"); + expect(chat.faux.summaryRequests).toHaveLength(1); + expect((await kinds(chat.root)).filter((kind) => kind === "pi.compaction")).toHaveLength(1); + await chat.harness.close(context); + }); + + for (const [name, prepare] of [ + [ + "declines", + (chat: Chat) => chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }), + ], + ["fails", (chat: Chat) => chat.faux.summaries.push(failure("bad request"))], + ] as const) { + it(`sends the request anyway when its compaction ${name}`, async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + prepare(chat); + await turn(chat, text("u4", 200), "a4"); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + expect(userText(chat.faux.agentRequests.at(-1)!.messages[0])).toBe(text("u1", 100)); + await chat.harness.close(context); + }); + } + + it("sends the request anyway when its compaction is aborted directly", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + chat.faux.agent.push(answer("a4")); + const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); + await reached.promise; + const [child] = await compactionTasks(chat); + await chat.harness.abortTask(child!.id, context); + expect((await input.wait(context)).status).toBe("done"); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + await chat.harness.close(context); + }); + + it("is aborted with its generation by Esc, before the generation's abort handler", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + const events: AgentEvent[] = []; + const stream = await watchEvents(chat.harness, chat.root.id, context); + stream.start(async (batch) => { + events.push(...batch); + }); + const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); + await reached.promise; + const [child] = await compactionTasks(chat); + await chat.root.abort(context); + expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); + expect((await chat.harness.getTask(child!.id, context))?.state).toMatchObject({ outcome: { status: "aborted" } }); + await waitFor(() => events.some((event) => event.type === "run_end")); + const types = events.map((event) => event.type); + expect(types.indexOf("compaction_end")).toBeLessThan(types.indexOf("run_end")); + await stream.stop(); + await chat.harness.close(context); + }); + + it("wins over a background compaction still in flight, which then settles stale", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(BACKGROUND, context); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary("BACKGROUND"), reached)); + await turn(chat, text("u4", 100), text("a4", 100)); + await reached.promise; + const [background] = await compactionTasks(chat); + chat.faux.summaries.push(summary("BLOCKING")); + await turn(chat, text("u5", 1000), "a5"); + expect(userText((await chat.root.context(context)).messages[0])).toContain("BLOCKING"); + gate.resolve(); + const outcome = await result(chat, background!.id as TaskId); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + expect(await (await chat.harness.submission(submissionId!, context))!.status(context)).toMatchObject({ + status: "unanswered", + reason: "stale", + }); + await chat.harness.close(context); + }); +}); + +describe("overflow compaction", () => { + const ENABLED: CompactionPolicy = { ...MANUAL, enabled: true }; + + it("compacts and retries with the same attempt, leaving the error out of the retry", async () => { + const chat = await open(); + await history(chat); + await chat.root.setCompaction(ENABLED, context); + chat.faux.summaries.push(summary()); + let attempt: number | undefined; + chat.faux.agent.push(failure(OVERFLOW)); + chat.faux.agent.push(async () => { + attempt = (await live(chat)).generation?.attempt; + return answer("fits"); + }); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + expect((await input.wait(context)).status).toBe("done"); + expect(attempt).toBe(1); + expect((await kinds(chat.root)).slice(-5)).toEqual([ + "pi.user", + "pi.assistant", + "pi.compaction", + "pi.system", + "pi.assistant", + ]); + const [compaction] = (await allEntries(chat.root)).filter((record) => record.kind === "pi.compaction"); + expect(compaction).toMatchObject({ data: { reason: "overflow" } }); + const retry = chat.faux.agentRequests.at(-1)!.messages; + expect(userText(retry[0])).toContain("SUMMARY"); + expect(retry.some((message) => message.role === "assistant" && message.stopReason === "error")).toBe(false); + await chat.harness.close(context); + }); + + it("fails a second overflow with its error entry", async () => { + const chat = await open(); + await history(chat); + await chat.root.setCompaction(ENABLED, context); + chat.faux.summaries.push(summary()); + chat.faux.agent.push(failure(OVERFLOW), failure(OVERFLOW)); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + expect(await input.wait(context)).toMatchObject({ + status: "unanswered", + reason: "model_error", + detail: OVERFLOW, + }); + expect((await kinds(chat.root)).filter((kind) => kind === "pi.compaction")).toHaveLength(1); + expect((await kinds(chat.root)).at(-1)).toBe("pi.assistant"); + await chat.harness.close(context); + }); + + it("fails without compacting when compaction is disabled, even for a retryable-looking overflow", async () => { + const chat = await open(); + await history(chat); + chat.faux.agent.push(failure(`overloaded: ${OVERFLOW}`)); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error" }); + expect(chat.faux.agentRequests).toHaveLength(4); + expect(chat.faux.summaryRequests).toHaveLength(0); + await chat.harness.close(context); + }); + + it("fails after a blocking threshold compaction in the same generation", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + chat.faux.summaries.push(summary()); + chat.faux.agent.push(failure(OVERFLOW)); + const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); + expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error" }); + expect(chat.faux.summaryRequests).toHaveLength(1); + await chat.harness.close(context); + }); + + for (const [name, prepare, requests] of [ + [ + "declines", + (chat: Chat) => chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }), + 0, + ], + ["fails", (chat: Chat) => chat.faux.summaries.push(failure("bad request")), 1], + // Classification finds no cut, so no compaction starts and the ordinary failure carries the text. + ["cannot cut", (chat: Chat) => chat.root.setCompaction({ ...ENABLED, keepRecentTokens: 100_000 }, context), 0], + ] as const) { + it(`fails with the overflow text when compaction ${name}`, async () => { + const chat = await open(); + await turn(chat, text("u1", 100), text("a1", 100)); + await chat.root.setCompaction(ENABLED, context); + await prepare(chat); + chat.faux.agent.push(failure(OVERFLOW)); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + expect(await input.wait(context)).toMatchObject({ + status: "unanswered", + reason: "model_error", + detail: OVERFLOW, + }); + expect(chat.faux.summaryRequests).toHaveLength(requests); + await chat.harness.close(context); + }); + } +}); + +describe("compaction estimates and interactions", () => { + for (const clock of ["real", "fixed"] as const) { + it(`ignores usage measured before a summary placed mid-run (${clock} clock)`, async () => { + const setup = chatSetup({ models: [{ id: "faux-1", contextWindow: 2000, maxTokens: 900 }] }); + if (clock === "fixed") setup.now = () => 1_000; + const chat = await open({ contextWindow: 2000 }, setup); + const toolGate = deferred(); + const toolReached = deferred(); + chat.setup.registry.tools.add({ + name: "slow", + description: "slow", + parameters: Type.Object({}), + execute: async () => { + toolReached.resolve(); + await toolGate.promise; + return { content: [{ type: "text", text: text("result", 200) }] }; + }, + }); + await chat.root.setActiveTools(["slow"], context); + await turn(chat, text("u1", 220), text("a1", 220)); + await turn(chat, text("u2", 220), text("a2", 220)); + await turn(chat, text("u3", 220), text("a3", 220)); + // Background at 900, blocking at 1500: the tool call's usage plus its result would cross 1500. + await chat.root.setCompaction({ ...BACKGROUND, backgroundTokens: 600 }, context); + // The request starts a background compaction; its tool call's usage measures the whole context. + chat.faux.agent.push(fauxAssistantMessage([fauxToolCall("slow", {})], { stopReason: "toolUse" })); + chat.faux.agent.push(answer("done")); + const summaryGate = deferred(); + const summaryReached = deferred(); + chat.faux.summaries.push(gated(summaryGate, summary(), summaryReached)); + const input = await chat.root.submit({ type: "input", content: text("u4", 50) }, context); + await Promise.all([toolReached.promise, summaryReached.promise]); + const [background] = await compactionTasks(chat); + summaryGate.resolve(); + // Queued: the run is busy in its tool round. + const queued = await result(chat, background!.id as TaskId); + const submissionId = queued.status === "completed" ? queued.result.submissionId! : undefined; + expect((await (await chat.harness.submission(submissionId!, context))!.status(context)).status).toBe("queued"); + toolGate.resolve(); + expect((await input.wait(context)).status).toBe("done"); + // The summary landed at postTools; the successor saw a small context and did not compact again. + expect(chat.faux.summaryRequests).toHaveLength(1); + expect(await compactionTasks(chat)).toEqual([]); + expect(userText(chat.faux.agentRequests.at(-1)!.messages[0])).toContain("SUMMARY"); + await chat.harness.close(context); + }); + } + + it("rebaselines the system prompt over kept system deltas", async () => { + const chat = await open(); + let mood = "cheerful"; + chat.setup.registry.systemPrompt.section("mood", () => mood, { tag: false }); + await turn(chat, text("u1", 100), text("a1", 100)); + await turn(chat, text("u2", 100), text("a2", 100)); + mood = "terse"; + await turn(chat, text("u3", 100), text("a3", 100)); + await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: 250 }, context); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + // The kept range holds the delta for the terse mood; the next request has one complete baseline. + expect((await chat.root.context(context)).entries.some((record) => record.kind === "pi.system")).toBe(true); + await turn(chat, "next", "ok"); + const systems = chat.faux.agentRequests.at(-1)!.messages.filter((message) => message.role === "system"); + expect(systems).toHaveLength(1); + expect(systems[0]).toMatchObject({ sections: { preamble: "You are helpful.", mood: "terse" } }); + await chat.harness.close(context); + }); + + it("summarizes a replaced entry's replacement and shows it to the hook", async () => { + const chat = await open(); + await history(chat); + const [u1] = await allEntries(chat.root); + await chat.root.submit( + { + type: "write", + entry: { + kind: "app.redact", + edits: [ + { + target: u1!.id, + action: "replace", + messages: [{ role: "user", content: "REDACTED", timestamp: 0 }], + }, + ], + }, + }, + context, + ); + let messages: readonly Message[] = []; + chat.setup.registry.hooks.add(CompactionTask, { + beforeCompact: (compaction) => { + messages = compaction.messages; + return undefined; + }, + }); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + expect(userText(messages[0])).toBe("REDACTED"); + expect(userText(chat.faux.summaryRequests[0]!.messages[1])).toContain("[User]: REDACTED"); + await chat.harness.close(context); + }); + + it("compacts a fork whose cut falls on a parent entry", async () => { + const chat = await open(); + await history(chat); + const entries = await allEntries(chat.root); + const fork = await chat.root.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context); + chat.faux.summaries.push(summary()); + const outcome = await result(chat, await fork.compact(undefined, context)); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + expect((await (await chat.harness.submission(submissionId!, context))!.wait(context)).status).toBe("done"); + const u3 = entries.find((record) => userText(record.model?.[0]).startsWith("u3"))!; + const view = await fork.context(context); + expect(view.head).toMatchObject({ kind: "pi.compaction", head: u3.id, conversationId: fork.id }); + expect(view.messages.slice(1).map(userText)).toEqual([text("u3", 100), text("a3", 100)]); + // The parent is untouched. + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + // The fork's view keeps the parent entries the summary kept. + const state = await fork.viewState(context); + expect(state.value.entries).toEqual(view.entries); + state.dispose(); + await chat.harness.close(context); + }); + + it("settles stale in a fork reset while it summarizes", async () => { + const chat = await open(); + await history(chat); + const entries = await allEntries(chat.root); + const fork = await chat.root.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + const id = await fork.compact(undefined, context); + await reached.promise; + await fork.reset(undefined, context); + gate.resolve(); + const outcome = await result(chat, id); + const submission = (await chat.harness.submission( + (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, + context, + ))!; + expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "stale" }); + expect((await fork.context(context)).head?.kind).toBe("pi.reset"); + await chat.harness.close(context); + }); + + it("places an older queued summary and the current one together when idle admission drains the inbox", async () => { + const chat = await open(); + await history(chat); + const gate = deferred(); + const reached = deferred(); + chat.faux.agent.push(gated(gate, failure("bad request"), reached)); + const failed = await chat.root.submit({ type: "input", content: "fails" }, context); + await reached.promise; + chat.faux.summaries.push(summary("OLDER")); + const older = await result(chat, await chat.root.compact(undefined, context)); + gate.resolve(); + await failed.wait(context); + // Idle now, with the older summary still queued; the current one queues behind it and a final boundary runs. + chat.faux.summaries.push(summary("CURRENT")); + const current = await result(chat, await chat.root.compact(undefined, context)); + const statuses = []; + for (const outcome of [older, current]) { + const id = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + statuses.push((await (await chat.harness.submission(id!, context))!.status(context)).status); + } + expect(statuses).toEqual(["done", "done"]); + expect(userText((await chat.root.context(context)).messages[0])).toContain("CURRENT"); + await chat.harness.close(context); + }); + + it("places a hook's summary and holds while work the hook created runs", async () => { + const chat = await open(); + const childGate = deferred(); + const Child = defineChildTask(childGate); + chat.setup.registry.tasks.add(Child); + chat.setup.registry.hooks.add(CompactionTask, { + beforeCompact: async (_compaction, api, hookContext) => { + await chat.harness.commit(async (tx) => { + await tx.createTask( + Child, + {}, + { ownership: { kind: "task", taskId: api.taskId }, conversationId: chat.root.id }, + ); + }, hookContext); + return { summary: "HOOK" }; + }, + }); + await history(chat); + const id = await chat.root.compact(undefined, context); + await waitFor(async () => (await chat.harness.getTask(id, context))?.state.status === "completing"); + // The summary and the status removal landed at the hold. + expect((await kinds(chat.root)).at(-1)).toBe("pi.compaction"); + expect((await live(chat)).compactions).toBeUndefined(); + childGate.resolve(); + expect((await result(chat, id)).status).toBe("completed"); + await chat.harness.close(context); + }); + + it("removes the status of a faulted compaction", async () => { + const chat = await open(); + await history(chat); + const getModel = chat.setup.models.getModel.bind(chat.setup.models); + chat.setup.models.getModel = () => { + throw new Error("models broke"); + }; + const outcome = await result(chat, await chat.root.compact(undefined, context)); + chat.setup.models.getModel = getModel; + expect(outcome).toMatchObject({ status: "faulted", error: { message: "models broke" } }); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + }); +}); + +function defineChildTask(gate: Deferred) { + return defineTask, { phase: "run" }, null>({ + name: "test.child", + version: 1, + initial: () => ({ phase: "run" }), + phases: { + run: async (_task, runtime, taskContext) => { + await gate.promise; + await runtime.commit( + () => ({ status: "terminal", outcome: { status: "completed", result: null } }), + taskContext, + ); + }, + }, + abort: async (_task, runtime, taskContext) => { + await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext); + }, + }); +} + +describe("compaction events and live status", () => { + it("reports start and end, and the retry backoff in a late joiner's snapshot", async () => { + const chat = await open(); + await history(chat); + await chat.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 60_000 }, context); + const events: AgentEvent[] = []; + const stream = await watchEvents(chat.harness, chat.root.id, context); + stream.start(async (batch) => { + events.push(...batch); + }); + chat.faux.summaries.push(failure("overloaded")); + const id = await chat.root.compact(undefined, context); + await waitFor(async () => (await live(chat)).compactions?.[0]?.retry !== undefined); + const late = await watchEvents(chat.harness, chat.root.id, context); + expect(late.snapshot.compactions).toEqual([ + { + taskId: id, + reason: "manual", + blocking: false, + attempt: 1, + retry: { at: expect.any(Number), error: "overloaded" }, + }, + ]); + await late.stop(); + await chat.harness.abortTask(id, context); + await waitFor(() => events.some((event) => event.type === "compaction_end")); + expect(events.filter((event) => event.type.startsWith("compaction_"))).toEqual([ + { type: "compaction_start", taskId: id, reason: "manual", blocking: false }, + { type: "compaction_end", taskId: id, reason: "manual" }, + ]); + await stream.stop(); + await chat.harness.close(context); + }); + + it("lists concurrent compactions in task ID order", async () => { + const chat = await open(); + await history(chat); + const first = deferred(); + const second = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), first), gated(deferred(), summary(), second)); + const a = await chat.root.compact(undefined, context); + const b = await chat.root.compact(undefined, context); + await Promise.all([first.promise, second.promise]); + expect((await live(chat)).compactions?.map((status) => status.taskId)).toEqual([a, b]); + await chat.root.abort(context); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + }); +}); + +// ─── Recovery ───────────────────────────────────────────────────────────── + +describe("compaction recovery", () => { + async function reopen(path: string, setup: ChatSetup, faux: Script): Promise { + const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); + harness.resume(); + return { harness, root, setup, faux }; + } + + async function first(path: string) { + const setup = chatSetup(); + setup.registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false }); + const faux = script(setup); + const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); + await root.setCompaction(MANUAL, context); + await root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 1 }, context); + harness.resume(); + const chat = { harness, root, setup, faux }; + await history(chat); + return chat; + } + + it("repeats nothing after reopen once the summary is placed", async () => { + const path = await sqlitePath(); + let chat = await first(path); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + const entries = await allEntries(chat.root); + const usage = await chat.harness.snapshot(UsageDoc, chat.root.id, context); + await chat.harness.close(context); + chat = await reopen(path, chat.setup, chat.faux); + await chat.harness.waitForIdle(context); + expect(chat.faux.summaryRequests).toHaveLength(1); + expect(await allEntries(chat.root)).toEqual(entries); + expect(await chat.harness.snapshot(UsageDoc, chat.root.id, context)).toEqual(usage); + const inspection = await chat.harness.inspect(context); + expect([inspection.tasks, inspection.submissions]).toEqual([[], []]); + await chat.harness.close(context); + }); + + it("fails an overflow run with its text when its compaction fails after reopen", async () => { + const path = await sqlitePath(); + let chat = await first(path); + await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + chat.faux.agent.push(failure(OVERFLOW)); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + await reached.promise; + const generation = (await live(chat)).run!.taskId; + expect((await chat.harness.getTask(generation, context))?.state).toMatchObject({ + status: "waiting", + checkpoint: { phase: "prepare", overflow: OVERFLOW }, + }); + await chat.harness.close(context); + chat = await reopen(path, chat.setup, chat.faux); + chat.faux.summaries.push(failure("bad request")); + const settled = await (await chat.harness.submission(input.id, context))!.wait(context); + expect(settled).toMatchObject({ status: "unanswered", reason: "model_error", detail: OVERFLOW }); + await chat.harness.close(context); + }); + + it("reruns select and its hook after a crash in select", async () => { + const path = await sqlitePath(); + let chat = await first(path); + let calls = 0; + const reached = deferred(); + chat.setup.registry.hooks.add(CompactionTask, { + beforeCompact: async (_compaction, _api, hookContext) => { + calls++; + if (calls === 1) { + reached.resolve(); + await aborted(hookContext.abortSignal!); + } + return undefined; + }, + }); + const id = await chat.root.compact(undefined, context); + await reached.promise; + await chat.harness.close(context); + chat = await reopen(path, chat.setup, chat.faux); + chat.faux.summaries.push(summary()); + expect((await result(chat, id)).status).toBe("completed"); + expect(calls).toBe(2); + await chat.harness.close(context); + }); + + it("resends an interrupted summarization once and counts only the answered attempt", async () => { + const path = await sqlitePath(); + let chat = await first(path); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + const usageBefore = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; + expect((await chat.harness.getTask(id, context))?.state).toMatchObject({ checkpoint: { phase: "summarize" } }); + await chat.harness.close(context); + chat = await reopen(path, chat.setup, chat.faux); + chat.faux.summaries.push(summary()); + expect((await result(chat, id)).status).toBe("completed"); + expect(chat.faux.summaryRequests).toHaveLength(2); + const [, resent] = chat.faux.summaryRequests; + expect(resent!.messages).toEqual( + chat.faux.summaryRequests[0]!.messages.map((message) => ({ ...message, timestamp: expect.any(Number) })), + ); + const usage = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; + expect(usage.input).toBeGreaterThan(usageBefore.input); + expect(usage.output - usageBefore.output).toBe(2); + await chat.harness.close(context); + }); + + it("resumes a retry backoff after reopen", async () => { + const path = await sqlitePath(); + let chat = await first(path); + let now = Date.now(); + chat.setup.now = () => now; + await chat.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 60_000 }, context); + chat.faux.summaries.push(failure("overloaded")); + const id = await chat.root.compact(undefined, context); + await waitFor(async () => (await live(chat)).compactions?.[0]?.retry !== undefined); + expect((await chat.harness.getTask(id, context))?.state).toMatchObject({ + checkpoint: { phase: "retry", attempt: 1 }, + }); + await chat.harness.close(context); + now += 120_000; + chat = await reopen(path, chat.setup, chat.faux); + chat.faux.summaries.push(summary()); + expect((await result(chat, id)).status).toBe("completed"); + expect(chat.faux.summaryRequests).toHaveLength(2); + await chat.harness.close(context); + }); + + it("keeps a generation waiting on its blocking compaction across reopen", async () => { + const path = await sqlitePath(); + let chat = await first(path); + const setup = chat.setup; + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + // The faux model window is 128k; this blocking threshold needs a small context window. + await chat.root.setCompaction({ ...BLOCKING, reserveTokens: 128_000 - 700 }, context); + const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); + await reached.promise; + await chat.harness.close(context); + chat = await reopen(path, setup, chat.faux); + chat.faux.summaries.push(summary()); + chat.faux.agent.push(answer("a4")); + expect((await (await chat.harness.submission(input.id, context))!.wait(context)).status).toBe("done"); + expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.compaction", "pi.system", "pi.assistant"]); + await chat.harness.close(context); + }); + + it("keeps a queued summary across reopen and places it at the next boundary", async () => { + const path = await sqlitePath(); + let chat = await first(path); + const reached = deferred(); + chat.faux.agent.push(gated(deferred(), answer("never"), reached)); + const input = await chat.root.submit({ type: "input", content: "busy" }, context); + await reached.promise; + chat.faux.summaries.push(summary()); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + await chat.harness.close(context); + chat = await reopen(path, chat.setup, chat.faux); + chat.faux.agent.push(answer("answered")); + expect((await (await chat.harness.submission(input.id, context))!.wait(context)).status).toBe("done"); + const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; + expect((await (await chat.harness.submission(submissionId!, context))!.wait(context)).status).toBe("done"); + await chat.harness.close(context); + }); +}); + +describe("compaction and the inbox", () => { + async function queuedSummary(chat: Chat, content = "SUMMARY") { + chat.faux.summaries.push(summary(content)); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + return (await chat.harness.submission( + (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, + context, + ))!; + } + + async function busy(chat: Chat, reply = answer("done")) { + const gate = deferred(); + const reached = deferred(); + chat.faux.agent.push(gated(gate, reply, reached)); + const input = await chat.root.submit({ type: "input", content: "busy" }, context); + await reached.promise; + return { input, release: () => gate.resolve() }; + } + + it("places a reset queued after the summary last, and makes a summary queued after a reset stale", async () => { + for (const order of ["summary first", "reset first"] as const) { + const chat = await open(); + await history(chat); + const run = await busy(chat); + let submission: Awaited>; + if (order === "summary first") { + submission = await queuedSummary(chat); + await chat.root.reset(undefined, context); + } else { + await chat.root.reset(undefined, context); + submission = await queuedSummary(chat); + } + run.release(); + await run.input.wait(context); + const settled = await submission.wait(context); + expect(settled.status).toBe(order === "summary first" ? "done" : "unanswered"); + expect((await kinds(chat.root)).at(-1)).toBe("pi.reset"); + expect((await chat.root.context(context)).head?.kind).toBe("pi.reset"); + await chat.harness.close(context); + } + }); + + it("places a summary left queued by a failed run at the next submission, before its input", async () => { + const chat = await open(); + await history(chat); + const run = await busy(chat, failure("bad request")); + const submission = await queuedSummary(chat); + run.release(); + expect((await run.input.wait(context)).status).toBe("unanswered"); + expect((await submission.status(context)).status).toBe("queued"); + await turn(chat, "again", "ok"); + expect((await submission.status(context)).status).toBe("done"); + const request = chat.faux.agentRequests.at(-1)!.messages; + expect(userText(request[0])).toContain("SUMMARY"); + expect(request.map(userText)).toContain("again"); + await chat.harness.close(context); + }); + + it("keeps the full retry budget after an overflow compaction", async () => { + const chat = await open(); + await history(chat); + await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + chat.faux.summaries.push(summary()); + chat.faux.agent.push( + failure("prompt is too long"), + failure("overloaded"), + failure("overloaded"), + answer("finally"), + ); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + expect((await input.wait(context)).status).toBe("done"); + await chat.harness.close(context); + }); + + it("loses an application edit placed while a compaction summarizes (spec §12)", async () => { + const chat = await open(); + await history(chat); + const [u1] = await allEntries(chat.root); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + const edit = { target: u1!.id, action: "replace", messages: [user("REDACTED")] } as const; + await chat.root.submit({ type: "write", entry: { kind: "app.redact", edits: [edit] } }, context); + gate.resolve(); + await result(chat, id); + // The summary was made from the unredacted entry, and the edit's target left the range. + expect(userText(chat.faux.summaryRequests[0]!.messages[1])).toContain("[User]: u1 "); + expect((await chat.root.context(context)).messages.map(userText)).not.toContain("REDACTED"); + await chat.harness.close(context); + }); + + it("keeps the kept entries mounted in the conversation view", async () => { + const chat = await open(); + await history(chat); + const state = await chat.root.viewState(context); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + await waitFor(() => state.value.entries[0]?.kind === "pi.compaction"); + expect(state.value.entries).toEqual((await chat.root.context(context)).entries); + state.dispose(); + await chat.harness.close(context); + }); +}); + +describe("compaction edge cases", () => { + it("keeps the one-compaction limit through a retry backoff", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + let asked = 0; + chat.setup.registry.hooks.add(CompactionTask, { + beforeCompact: () => { + asked++; + return { decline: true }; + }, + }); + chat.faux.agent.push(failure("overloaded")); + await turn(chat, text("u4", 200), "a4"); + // One blocking compaction, declined; the retried preparation, still above the threshold, started no other. + expect(asked).toBe(1); + expect(chat.faux.agentRequests).toHaveLength(5); + await chat.harness.close(context); + }); + + it("does not start a background compaction when a manual one was admitted during preparation", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(BACKGROUND, context); + const rendering = deferred(); + const release = deferred(); + let hold = true; + chat.setup.registry.systemPrompt.section("slow", async () => { + if (hold) { + hold = false; + rendering.resolve(); + await release.promise; + } + return "slow"; + }); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + chat.faux.agent.push(answer("a4")); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + await rendering.promise; + const manual = await chat.root.compact(undefined, context); + await reached.promise; + release.resolve(); + expect((await input.wait(context)).status).toBe("done"); + expect((await compactionTasks(chat)).map((task) => task.id)).toEqual([manual]); + // A background compaction would have sent its own summarization request. + expect(chat.faux.summaryRequests).toHaveLength(1); + await chat.harness.abortTask(manual, context); + await chat.harness.close(context); + }); + + it("starts no background compaction after a blocking one in the same generation", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + // Blocking at 1500, background at 200: the kept part stays above the background threshold. + await chat.root.setCompaction({ ...BACKGROUND, backgroundTokens: 1300, keepRecentTokens: 400 }, context); + chat.faux.summaries.push(summary()); + await turn(chat, text("u4", 1000), "a4"); + expect(chat.faux.summaryRequests).toHaveLength(1); + expect(await compactionTasks(chat)).toEqual([]); + await chat.harness.close(context); + }); + + it("sends the request anyway when its blocking compaction faults", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + const completeSimple = chat.setup.models.completeSimple.bind(chat.setup.models); + chat.setup.models.completeSimple = () => { + throw new Error("no credentials"); + }; + await turn(chat, text("u4", 200), "a4"); + chat.setup.models.completeSimple = completeSimple; + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + }); + + it("fails an overflow run with its text when its compaction is aborted directly or itself overflows", async () => { + for (const how of ["abort", "overflow"] as const) { + const chat = await open(); + await history(chat); + await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + const reached = deferred(); + if (how === "abort") chat.faux.summaries.push(gated(deferred(), summary(), reached)); + else chat.faux.summaries.push(failure("prompt is too long for the summary")); + chat.faux.agent.push(failure(OVERFLOW)); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + if (how === "abort") { + await reached.promise; + const [child] = await compactionTasks(chat); + await chat.harness.abortTask(child!.id, context); + } + expect(await input.wait(context)).toMatchObject({ + status: "unanswered", + reason: "model_error", + detail: OVERFLOW, + }); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + } + }); + + it("treats a length stop as an ordinary answer, not an overflow", async () => { + const chat = await open(); + await history(chat); + await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + chat.faux.agent.push(fauxAssistantMessage("cut short", { stopReason: "length", errorMessage: OVERFLOW })); + const input = await chat.root.submit({ type: "input", content: "go" }, context); + expect((await input.wait(context)).status).toBe("done"); + expect(chat.faux.summaryRequests).toHaveLength(0); + await chat.harness.close(context); + }); + + it("orders the events of a summary placed at once", async () => { + const chat = await open(); + await history(chat); + const events: AgentEvent[] = []; + const stream = await watchEvents(chat.harness, chat.root.id, context); + stream.start(async (batch) => { + events.push(...batch); + }); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + await waitFor(() => events.some((event) => event.type === "compaction_end")); + const end = events.findIndex((event) => event.type === "compaction_end"); + const batch = events.slice(end - 2, end + 3).map((event) => event.type); + expect(batch).toEqual(["message_start", "message_end", "compaction_end", "submission", "usage_changed"]); + await stream.stop(); + await chat.harness.close(context); + }); + + it("puts compaction_start last in the batch of the preparation commit", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(BACKGROUND, context); + const events: AgentEvent[][] = []; + const stream = await watchEvents(chat.harness, chat.root.id, context); + stream.start(async (batch) => { + events.push([...batch]); + }); + // A new section makes the preparation commit append a system entry next to the compaction's status. + chat.setup.registry.systemPrompt.section("extra", () => "extra"); + chat.faux.summaries.push(gated(deferred(), summary())); + await turn(chat, text("u4", 100), "a4"); + await waitFor(() => events.some((batch) => batch.some((event) => event.type === "compaction_start"))); + const batch = events.find((candidate) => candidate.some((event) => event.type === "compaction_start"))!; + expect(batch.map((event) => event.type)).toEqual(["message_start", "message_end", "compaction_start"]); + await stream.stop(); + await chat.harness.abortTask((await compactionTasks(chat))[0]!.id, context); + await chat.harness.close(context); + }); + + it("keeps a background summary queued through a retry backoff while a blocking compaction wins", async () => { + const chat = await open({ contextWindow: 2000 }); + await history(chat); + await chat.root.setCompaction(BACKGROUND, context); + await chat.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 300 }, context); + const summaryGate = deferred(); + const summaryReached = deferred(); + chat.faux.summaries.push(gated(summaryGate, summary("BACKGROUND"), summaryReached)); + chat.faux.summaries.push(summary("BLOCKING")); + const failed = deferred(); + chat.faux.agent.push(async () => { + failed.resolve(); + return failure("overloaded"); + }); + chat.faux.agent.push(answer("a4")); + const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); + await Promise.all([failed.promise, summaryReached.promise]); + // During the backoff: the background summary queues, and the thresholds drop so the retry blocks. + await waitFor(async () => (await live(chat)).generation?.retry !== undefined); + const [background] = await compactionTasks(chat); + summaryGate.resolve(); + const queued = await result(chat, background!.id as TaskId); + const submission = (await chat.harness.submission( + (queued.status === "completed" ? queued.result.submissionId : undefined)!, + context, + ))!; + expect((await submission.status(context)).status).toBe("queued"); + await chat.root.setCompaction({ ...BACKGROUND, reserveTokens: 1500, keepRecentTokens: 50 }, context); + expect((await input.wait(context)).status).toBe("done"); + expect(userText(chat.faux.agentRequests.at(-1)!.messages[0])).toContain("BLOCKING"); + expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" }); + await chat.harness.close(context); + }); + + it("summarizes the previous summary in a second compaction", async () => { + const chat = await open(); + await history(chat); + chat.faux.summaries.push(summary("FIRST")); + await result(chat, await chat.root.compact(undefined, context)); + await turn(chat, text("u4", 100), text("a4", 100)); + await turn(chat, text("u5", 100), text("a5", 100)); + chat.faux.summaries.push(summary("SECOND")); + await result(chat, await chat.root.compact(undefined, context)); + const prompt = userText(chat.faux.summaryRequests[1]!.messages[1]); + expect(prompt).toMatch(/^\n\[User\]: The conversation history before this point was compacted/); + expect(prompt).toContain("FIRST"); + expect(userText((await chat.root.context(context)).messages[0])).toContain("SECOND"); + await chat.harness.close(context); + }); + + it("leaves no usage, submission, summary, or outcome when the classifying commit is rejected", async () => { + const storage = new ControlledStorage(); + const chat = await open({ storage }); + await history(chat); + const usage = async () => (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]; + const before = await usage(); + chat.faux.summaries.push(async () => { + storage.failNextCommit(new StorageRejected("rejected")); + return summary(); + }); + const id = await chat.root.compact(undefined, context); + const outcome = await result(chat, id); + expect(outcome.status).toBe("faulted"); + expect(await usage()).toEqual(before); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + expect((await chat.harness.inspect(context)).submissions).toEqual([]); + expect(await storage.submissionByRequest(chat.root.id, `compaction:${id}`, context)).toBeUndefined(); + expect((await live(chat)).compactions).toBeUndefined(); + await chat.harness.close(context); + }); +}); + +describe("blocking and manual compaction together", () => { + /** A run whose generation waits on a blocking compaction whose summary is held; returns the input and gate. */ + async function blockingRun(chat: Chat) { + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + const gate = deferred(); + const reached = deferred(); + chat.faux.summaries.push(gated(gate, summary("BLOCKING"), reached)); + const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); + await reached.promise; + const [blocking] = await compactionTasks(chat); + return { input, gate, blocking: blocking!.id as TaskId }; + } + + it("places a manual summary selected before the blocking one landed; its equal cut replaces it", async () => { + const chat = await open({ contextWindow: 1000 }); + const run = await blockingRun(chat); + // Selected from the same context as the blocking compaction, so it cuts at the same entry. + chat.faux.summaries.push(summary("MANUAL")); + const manual = await result(chat, await chat.root.compact(undefined, context)); + const submission = (await chat.harness.submission( + (manual.status === "completed" ? manual.result.submissionId : undefined)!, + context, + ))!; + expect((await submission.status(context)).status).toBe("queued"); + chat.faux.agent.push(answer("a4")); + run.gate.resolve(); + expect((await run.input.wait(context)).status).toBe("done"); + // The request after the blocking compaction used its summary; the manual one landed at the final boundary. + expect(userText(chat.faux.agentRequests.at(-1)!.messages[0])).toContain("BLOCKING"); + expect((await submission.wait(context)).status).toBe("done"); + const markers = (await allEntries(chat.root)).filter((record) => record.kind === "pi.compaction"); + expect(markers).toHaveLength(2); + expect(markers[1]!.head).toBe(markers[0]!.head); + const messages = (await chat.root.context(context)).messages; + expect(userText(messages[0])).toContain("MANUAL"); + expect(messages.some((message) => userText(message).includes("BLOCKING"))).toBe(false); + await chat.harness.close(context); + }); + + it("finds nothing to compact for a manual compaction selected after the blocking summary landed", async () => { + const chat = await open({ contextWindow: 1000 }); + const run = await blockingRun(chat); + const answerGate = deferred(); + const answerReached = deferred(); + chat.faux.agent.push(gated(answerGate, answer("a4"), answerReached)); + run.gate.resolve(); + await answerReached.promise; + expect(await result(chat, await chat.root.compact(undefined, context))).toEqual({ + status: "completed", + result: {}, + }); + expect(chat.faux.summaryRequests).toHaveLength(1); + answerGate.resolve(); + expect((await run.input.wait(context)).status).toBe("done"); + await chat.harness.close(context); + }); + + it("aborts the run and both compactions on Esc and appends nothing", async () => { + const chat = await open({ contextWindow: 1000 }); + const run = await blockingRun(chat); + const manualReached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary("MANUAL"), manualReached)); + const manual = await chat.root.compact(undefined, context); + await manualReached.promise; + expect((await live(chat)).compactions?.map((status) => status.blocking)).toEqual([true, false]); + await chat.root.abort(context); + expect(await run.input.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); + expect((await result(chat, run.blocking)).status).toBe("aborted"); + expect((await result(chat, manual)).status).toBe("aborted"); + expect((await live(chat)).compactions).toBeUndefined(); + expect((await live(chat)).run).toBeUndefined(); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + expect((await chat.harness.inspect(context)).submissions).toEqual([]); + await chat.harness.close(context); + }); + + it("places a summary that survived Esc at the next submission, before its input", async () => { + const chat = await open(); + await history(chat); + const reached = deferred(); + chat.faux.agent.push(gated(deferred(), answer("never"), reached)); + const busy = await chat.root.submit({ type: "input", content: "busy" }, context); + await reached.promise; + chat.faux.summaries.push(summary()); + const outcome = await result(chat, await chat.root.compact(undefined, context)); + const submission = (await chat.harness.submission( + (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, + context, + ))!; + await chat.root.abort(context); + expect(await busy.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); + expect((await submission.status(context)).status).toBe("queued"); + await turn(chat, "u5", "a5"); + expect((await submission.status(context)).status).toBe("done"); + const request = chat.faux.agentRequests.at(-1)!.messages; + expect(userText(request[0])).toContain("SUMMARY"); + expect(request.map(userText)).toContain("u5"); + const tail = (await allEntries(chat.root)).slice(-4).map((record) => record.kind); + expect(tail).toEqual(["pi.compaction", "pi.user", "pi.system", "pi.assistant"]); + await chat.harness.close(context); + }); +}); + +describe("compaction pinning, silent overflow, and late policy changes", () => { + it("keeps the pinned model through a retry and advances the live attempt", async () => { + const setup = chatSetup({ + models: [ + { id: "faux-1", contextWindow: 100_000, maxTokens: 900 }, + { id: "faux-2", contextWindow: 100_000, maxTokens: 900 }, + ], + }); + const chat = await open({}, setup); + await history(chat); + let attempt: number | undefined; + chat.faux.summaries.push(async () => { + // Switched during the attempt: the retry still uses the pinned model. + await chat.root.setModel({ provider: "faux", modelId: "faux-2" }, context); + return failure("overloaded"); + }); + chat.faux.summaries.push(async () => { + attempt = (await live(chat)).compactions?.[0]?.attempt; + return summary(); + }); + expect((await result(chat, await chat.root.compact(undefined, context))).status).toBe("completed"); + expect(chat.faux.summaryRequests.map((request) => request.model)).toEqual(["faux-1", "faux-1"]); + expect(attempt).toBe(2); + const usage = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models; + expect(Object.keys(usage)).toEqual(["faux/faux-1"]); + await chat.harness.close(context); + }); + + it("shows a late joiner a compaction that is summarizing", async () => { + const chat = await open(); + await history(chat); + const reached = deferred(); + chat.faux.summaries.push(gated(deferred(), summary(), reached)); + const id = await chat.root.compact(undefined, context); + await reached.promise; + const stream = await watchEvents(chat.harness, chat.root.id, context); + expect(stream.snapshot.compactions).toEqual([{ taskId: id, reason: "manual", blocking: false, attempt: 1 }]); + await stream.stop(); + await chat.harness.abortTask(id, context); + await chat.harness.close(context); + }); + + for (const [name, response] of [ + ["a stop whose input exceeds the window", answer("fine")], + ["a length stop that fills the window without output", fauxAssistantMessage("", { stopReason: "length" })], + ] as const) { + it(`treats silent overflow as an ordinary answer: ${name}`, async () => { + const chat = await open({ contextWindow: 300 }); + await history(chat); + // Thresholds out of reach, so only overflow classification could compact. + await chat.root.setCompaction( + { enabled: true, reserveTokens: -100_000, keepRecentTokens: 150, backgroundTokens: 0 }, + context, + ); + chat.faux.agent.push(response); + const input = await chat.root.submit({ type: "input", content: "go" }, context); + expect((await input.wait(context)).status).toBe("done"); + const [last] = (await allEntries(chat.root)).slice(-1); + const message = last!.model![0] as AssistantMessage; + expect(message.usage.input).toBeGreaterThanOrEqual(300); + expect(chat.faux.summaryRequests).toHaveLength(0); + await chat.harness.close(context); + }); + } + + it("sends the request when a blocking compaction finds nothing under a policy changed after preparation", async () => { + const chat = await open({ contextWindow: 1000 }); + await history(chat); + await chat.root.setCompaction(BLOCKING, context); + let changed = false; + chat.setup.registry.systemPrompt.section("policy", async () => { + // Rendering runs after preparation read the policy; the compaction reads this one. + if (!changed) { + changed = true; + await chat.root.setCompaction({ ...BLOCKING, keepRecentTokens: 100_000 }, context); + } + return "p"; + }); + await turn(chat, text("u4", 200), "a4"); + const compactions = (await chat.harness.inspect(context)).tasks.filter( + (task) => task.record.kind === "pi.compaction", + ); + expect(compactions).toEqual([]); + expect(chat.faux.summaryRequests).toHaveLength(0); + expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); + await chat.harness.close(context); + }); +}); + +describe("blocked compaction", () => { + it("survives reopen blocked and is orphaned on abort with its status removed", async () => { + const path = await sqlitePath(); + const setup = chatSetup(); + let { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); + // A compaction stored by a newer version than this process registers, for example after a downgrade. + const newer = { definition: { ...CompactionTask.definition, version: 2 } } as typeof CompactionTask; + const id = await root.commit(async (tx) => { + const taskId = await tx.createTask(newer, { reason: "manual" }, { ownership: { kind: "conversation" } }); + const live = await tx.doc(LiveDoc, root.id); + live.compactions = [{ taskId, reason: "manual", blocking: false, attempt: 1 }]; + return taskId; + }, context); + await harness.close(context); + ({ harness, root } = await openChat(await openNodeSqliteStorage(path), setup)); + harness.resume(); + const chat = { harness, root, setup, faux: script(setup) }; + const events: AgentEvent[] = []; + const stream = await watchEvents(chat.harness, chat.root.id, context); + stream.start(async (batch) => { + events.push(...batch); + }); + const inspection = await chat.harness.inspect(context); + expect(inspection.tasks.find((task) => task.record.id === id)?.state).toEqual({ + kind: "blocked", + reason: "task_too_old", + }); + await chat.harness.abortTask(id, context); + expect((await chat.harness.waitForTask(id, context)).state.outcome).toEqual({ + status: "orphaned", + reason: "task_too_old", + }); + expect((await live(chat)).compactions).toBeUndefined(); + await waitFor(() => events.some((event) => event.type === "compaction_end")); + await stream.stop(); + await chat.harness.close(context); + }); +}); + +describe("context contributions", () => { + it("apply edits carried by an older head marker in the range", async () => { + const chat = await open(); + const ids = await chat.root.commit(async (tx) => { + const a = await tx.appendEntry(chat.root.id, { kind: "app.note", model: [user("a")] }); + const b = await tx.appendEntry(chat.root.id, { kind: "app.note", model: [user("b")] }); + // An older marker that omits b, then a newer one whose range still contains the older marker. + await tx.appendEntry(chat.root.id, { + kind: "app.head", + head: a.id, + edits: [{ target: b.id, action: "omit" }], + }); + const c = await tx.appendEntry(chat.root.id, { kind: "app.note", model: [user("c")] }); + await tx.appendEntry(chat.root.id, { kind: "app.head", head: a.id, model: [user("H")] }); + return { a: a.id, b: b.id, c: c.id }; + }, context); + const view = await chat.root.context(context); + expect(view.entries.map((record) => record.id).slice(1)).toEqual([ids.a, ids.b, ids.c]); + expect(view.contributions.map((messages) => messages.map(userText))).toEqual([["H"], ["a"], [], ["c"]]); + expect(view.messages.map(userText)).toEqual(["H", "a", "c"]); + // The summarizer sees the same contributions: the omitted entry stays out. + await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: 1 }, context); + chat.faux.summaries.push(summary()); + await result(chat, await chat.root.compact(undefined, context)); + const prompt = userText(chat.faux.summaryRequests[0]!.messages[1]); + expect(prompt).toContain("\n[User]: H\n\n[User]: a\n"); + await chat.harness.close(context); + }); +}); diff --git a/packages/durable/test/harness-registry.test.ts b/packages/durable/test/harness-registry.test.ts index ce2a700e7..40b9df551 100644 --- a/packages/durable/test/harness-registry.test.ts +++ b/packages/durable/test/harness-registry.test.ts @@ -1,6 +1,7 @@ import type { JsonValue } from "@earendil-works/chord"; import { Type } from "@earendil-works/pi-ai"; import { + CompactionTask, createRegistry, defineTask, GenerationTask, @@ -208,7 +209,7 @@ describe("registry", () => { const worker = task("worker"); registry.tasks.add(worker); expect(() => registry.tasks.add(task("worker"))).toThrow("Task worker is already registered"); - expect(registry.tasks.list()).toEqual([GenerationTask, ToolTask, worker]); + expect(registry.tasks.list()).toEqual([GenerationTask, ToolTask, CompactionTask, worker]); const first = { beforeRun: () => {} }; const second = { beforeRun: () => {} }; @@ -230,7 +231,7 @@ describe("registry", () => { it("starts with undisposable, non-overridable built-in tasks", () => { const registry = createRegistry(); - expect(registry.tasks.list()).toEqual([GenerationTask, ToolTask]); + expect(registry.tasks.list()).toEqual([GenerationTask, ToolTask, CompactionTask]); expect(registry.snapshot().task("pi.generation")).toBe(GenerationTask); expect(() => registry.tasks.add({ definition: { ...GenerationTask.definition } })).toThrow( "Task pi.generation is already registered", diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index bcd774fdf..6932959cf 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -44,7 +44,7 @@ const BUDGETS = { "packages/durable": { ".": { // The built-in tool task validates arguments with pi-ai's TypeBox-based validation, so TypeBox is allowed. - maxFiles: 56, + maxFiles: 58, forbid: ["packages/ai/src/index.ts", "packages/ai/src/utils/typebox-helpers.ts"], }, },