round 1: implement bracket-first manual compaction

This commit is contained in:
Hypatia May
2026-07-30 17:40:25 +08:00
parent 86b95a3856
commit faac9b4fd5
101 changed files with 3452 additions and 295 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact/README.md
README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866
README.zh.md: 7763faad101a4f7f6f8034b76dff9284909667e2
README.md: 9c322db998a3179ac96e8fbee26727f3cedef7bb
README.zh.md: 2318df4dc5e34d5d35910f957ba75b3eef1488eb

View File

@@ -10,22 +10,25 @@ This package is the interface tier of the compaction capability, split so each c
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers |
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
| `@deepseek-ai/dsh-command-compact` | the human `/compact` command over `ctx.compact.compactNow()` |
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
## Service API (`ctx.compact`)
Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed.
## Tool-pairing boundaries
@@ -45,11 +48,15 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
## Blocking
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compact/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace.
The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compact/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compact/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released.
## Events
@@ -57,7 +64,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
## Implementing a backend
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Recognizing a checkpoint outside the host program (`./checkpoint`)
@@ -81,6 +88,6 @@ A successful backend replacement invalidates reuse from the first shadowed histo
## Known Limitations and Deferred Work
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
- **Human command, not a model tool** — `@deepseek-ai/dsh-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered.
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.

View File

@@ -10,22 +10,25 @@
|---|---|
| `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper |
| `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-tool-compact`(暂缓) | 面向模型`/compact` 工具,基于 `ctx.compact` 实现 |
| `@deepseek-ai/dsh-command-compact` | 面向用户`/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session``@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的并记录在 [压缩能力 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
## 服务 API`ctx.compact`
两个方法都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
三个操作都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
| 成员 | 语义 |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 |
| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 |
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq`agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start``end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。
`compactIfNeeded` 必须传入 `signal``compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose资源释放 会停止进行中的摘要,不会留下越过取消时点继续运行的遗留模型调用。可以从所拥有会话的日志(当前尚未结束的轮次恢复 `compact/*` 事件所属轮次,因此后端从日志中标记该值,而不信任调用方提供的值
`compactIfNeeded``compactNow` 必须传入 `signal``compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose资源释放会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`
`ManualCompactionError.code` 是封闭集合 `busy | changed | summary | commit | persistence``changed``summary` 表示所选会话表层未被替换,但日志仍会记录失败尝试。`commit` 有意不判断是否发生了部分变更;`persistence` 表示内存中的 bracket 已闭合,但显式 flush 失败。
## 工具配对边界
@@ -45,11 +48,15 @@
表层变更(第 4 步)位于锁的起止范围**内**`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start``compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`
这对标记表示获取和释放锁的时间点,并非排他的事件容器。手动摘要等待期间,空闲的 `inject()` 可以在 start 与 end 之间追加不相关的上下文。因此,手动稳定性检查会重新验证所选 span而不要求整个表层相等位置替换会让该注入上下文在检查点之后保持可见。自动压缩则要求其活动轮次内的整个表层保持相等。
`deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。
## 阻塞
压缩通过日志记录锁串行化`compactRegion` 会拒绝启动,条件是最后一个 `compact/start` 之后没有匹配的 `compact/end`。锁由日志记录(而非内存 mutex因此回放后仍然有效持久化后端也可以在重新加载时检测遗留 `compact/start`。锁会覆盖**整个**操作:摘要、`compact/summary` 溯源记录*以及* `user/message` 表层替换全部发生在 `compact/end` 之前,因此 `session/event` listener 即使在 `compact/end` 时触发,也绝不会看到锁已释放而表层变更仍在等待。基础后端会在摘要后重新验证已选表层:表层变更会导致拒绝,不相关的仅日志追加不会使替换失效。即使摘要抛出异常,也会追加 `compact/end`,因此失败绝不会将锁卡死
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compact/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态
锁就是持久标记对,而非 `WeakSet`、包装层 mutex 或客户端侧锚点。`compact/start` 会在摘要让出控制权之前同步追加。之后每次失败都会恰好尝试一次 `compact/end { error }`;如果追加该闭合事件本身失败,未匹配 start 会继续作为有意保留的 busy 信号,并且不会尝试 flush。已成功闭合的手动尝试即使报告 `changed``summary` 也会 flush从而在释放轮次接纳预留前保留该记录。
## 事件
@@ -57,7 +64,7 @@
## 实现后端
继承 `CompactService`,实现 `compactIfNeeded``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
继承 `CompactService`,实现 `compactIfNeeded``compactNow``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`
@@ -81,6 +88,6 @@
## 已知限制与暂缓事项
- **尚无面向模型的消费方层**`@deepseek-ai/dsh-tool-compact``/compact` 工具)已暂缓;只能通过直接 `ctx.compact` 调用或后端的自动 listener 进行压缩
- **面向用户的命令,而非模型工具**`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具
- **部分单元溢出不在契约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。

View File

@@ -21,12 +21,43 @@ export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoi
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
/** Expected failure classes for an explicit idle-session compaction request. */
export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence'
/** Expected manual-compaction failure suitable for a direct human-command result. */
export class ManualCompactionError extends Error {
override readonly name = 'ManualCompactionError'
/**
* Create one classified manual-compaction failure.
* @param code - stable failure class for a human-command consumer.
* @param message - backend diagnostic retained as the Error message.
* @param options - optional original failure.
*/
constructor(
readonly code: ManualCompactionErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
session: Session
options: { provider?: string; model?: string }
}
/**
* Agent capability required to serialize an explicit idle-session compaction
* against driver turns. The durable `compact/start` marker separately excludes
* other compaction transactions.
*/
export interface ManualCompactAgentContext extends CompactAgentContext {
reserveTurnAdmission(): (() => void) | undefined
}
declare module 'cordis' {
interface Context {
compact: CompactService
@@ -65,6 +96,29 @@ export abstract class CompactService extends Service {
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Explicitly compact useful history even below automatic pressure thresholds.
* Implementations reserve idle turn admission synchronously before any
* asynchronous work, select a useful range without writing on a no-op, then
* append a standalone `compact/start` before summarization. That durable
* marker is the compaction lock until one `compact/end` attempt. Later waking
* prompts remain accepted in FIFO order and start only after the optional
* durability checkpoint and admission release. Context injected while the
* summary runs may sit between the marker pair; only the selected span must
* remain stable.
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, changed-span,
* summarization/shrink, commit-stage, or persistence failures, and the exact
* abort reason when cancelled. Failed attempts remain visible in the log.
*/
abstract compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq

View File

@@ -13,7 +13,7 @@ export const name = 'compact-invariant'
export const inject = ['invariants']
interface CompactionTrace {
turn: number
turn: number | null
summarized: boolean
}
@@ -23,9 +23,30 @@ interface SessionTrace {
}
type CompactionTransition =
| { kind: 'start'; turn: number }
| { kind: 'summary'; turn: number }
| { kind: 'start'; turn: number | null }
| { kind: 'summary'; turn: number | null }
| { kind: 'end' }
| { kind: 'end-seed' }
/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
function validateOwner(
owner: number | null,
openTurn: number | null,
eventType: 'compact/start' | 'compact/summary' | 'compact/end',
fail: InvariantFailure,
): void {
if (owner === null) {
if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`)
return
}
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`)
if (owner !== openTurn) {
if (eventType === 'compact/summary') {
fail(`compact/summary belongs to turn ${owner} but open turn is ${openTurn}`)
}
fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
}
}
/** Validate one compaction event without advancing committed trace state. */
function validateCompactionEvent(
@@ -33,23 +54,22 @@ function validateCompactionEvent(
event: SessionEvent,
fail: InvariantFailure,
): CompactionTransition | undefined {
if (event.type === 'session/end-seed') return { kind: 'end-seed' }
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') {
return undefined
}
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`)
const open = trace.compaction
if (event.type === 'compact/start') {
if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`)
if (event.data.turn !== trace.openTurn) {
fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
if (open !== undefined) {
const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`
fail(`compact/start while ${owner} is still compacting`)
}
validateOwner(event.data.turn, trace.openTurn, event.type, fail)
return { kind: 'start', turn: event.data.turn }
}
if (event.type === 'compact/summary') {
if (open === undefined) fail('compact/summary has no matching compact/start')
if (open.turn !== trace.openTurn) {
fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (open.summarized) fail('compact/summary repeated within one compaction')
const seqs = event.data.shadowedSeqs
if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty')
@@ -63,11 +83,9 @@ function validateCompactionEvent(
}
if (open === undefined) fail('compact/end has no matching compact/start')
if (event.data.turn !== open.turn) {
fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`)
}
if (event.data.turn !== trace.openTurn) {
fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (event.data.error === undefined && !open.summarized) {
fail('successful compact/end requires one compact/summary')
}
@@ -114,7 +132,10 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
trace.openTurn = null
return
}
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return
if (event.type !== 'session/end-seed'
&& event.type !== 'compact/start'
&& event.type !== 'compact/summary'
&& event.type !== 'compact/end') return
const candidate = staged.get(event)
/* v8 ignore next -- internal/dispatch stages every compaction event */
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')

View File

@@ -11,8 +11,12 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
'compact/start': { turn: number }
/**
* Marks the start of a compaction — log-only, holds the lock until
* `compact/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compact/start': { turn: number | null }
/**
* Provenance record of a completed summarization — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement
@@ -40,8 +44,11 @@ declare module '@deepseek-ai/dsh-session' {
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
}
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
'compact/end': { turn: number; error?: string }
/**
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.
*/
'compact/end': { turn: number | null; error?: string }
}
}

View File

@@ -9,6 +9,7 @@ import {
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact'
/**
* A trivial concrete CompactService implementing the abstract contract. The
@@ -29,6 +30,14 @@ class StubCompactService extends CompactService {
return null
}
override async compactNow(
_agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
return null
}
override async compactRegion(
start: number,
end: number,
@@ -98,6 +107,12 @@ describe('CompactService seam', () => {
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
const signal = new AbortController().signal
expect(await svc.compactNow({
...stubAgent(session),
reserveTurnAdmission: () => () => undefined,
}, signal)).toBeNull()
expect(svc.lastSignal).toBe(signal)
})
it('compact/* events merge into SessionEventMap and are log-only', async () => {

View File

@@ -41,6 +41,38 @@ describe('compaction invariants', () => {
failed.append('compact/end', { turn: 2, error: 'provider failed' })
})
it('accepts standalone successful and failed compaction lifecycles between turns', async () => {
const ctx = await setup()
const success = ctx.sessions.create()
success.append('compact/start', { turn: null })
success.append('compact/summary', summary())
success.append('compact/end', { turn: null })
const failed = ctx.sessions.create()
failed.append('compact/start', { turn: null })
failed.append('compact/end', { turn: null, error: 'provider failed' })
})
it('clears an inherited open compaction trace at end-seed during replay', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-compaction-source'))
source.append('compact/start', { turn: null })
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type))
.toEqual(['compact/start', 'session/end-seed'])
await ctx.plugin(InvariantService)
await ctx.plugin(CompactInvariant)
expect(() => {
replayed.append('compact/start', { turn: null })
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
}).not.toThrow()
})
it('rebuilds an open trace when the companion loads after the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -78,6 +110,26 @@ describe('compaction invariants', () => {
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
})
it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => {
const ctx = await setup()
const open = ctx.sessions.create()
startTurn(open)
expect(() => open.append('compact/start', { turn: null }))
.toThrow(/standalone but turn 1 is open/)
const idle = ctx.sessions.create()
expect(() => idle.append('compact/start', { turn: 1 }))
.toThrow(/outside any open turn/)
})
it('attributes a nested standalone start to the standalone owner', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
session.append('compact/start', { turn: null })
expect(() => session.append('compact/start', { turn: null }))
.toThrow(/standalone compaction is still compacting/)
})
it('rejects an unenclosed compaction event when replaying an existing session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)