Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 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/README.md
README.md: 509ea2764250f42e492f787deba959a8dd9967b7
README.zh.md: adc52690dbe9e904d8f24199cb78493dab3333a4
README.md: 0b5a5b18b963dc7be0bd314420bdb3c88715533d
README.zh.md: 5d6d8a1c076e444094cc030c9495f346f40bf7ea

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a human command adapter. All **product** packages.
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): a Service Definition, a summarizing provider, a model-free tool-result pruning companion, and a human command Consumer. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
@@ -12,3 +12,5 @@ A compaction capability family (see [capability seams](../../.agents/notes/imple
| [`command-compact/`](command-compact/README.md) | Human compaction command | registers on `ctx.commands` |
The backend, optional pruner, and human command compose through the seam; token measurement remains a separate LLM-family service. The [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) owns the dependency rationale.
The subsystem reference — the `compact/*` events, `CompactionResult`, the service, pruning outcomes — is [docs/subsystems/compaction.md](../../docs/subsystems/compaction.md); the seam's deliberate `dsh-session`/`dsh-llm` dependency is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
一个压缩compaction能力家族参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)抽象接口、摘要后端、无模型工具结果修剪配套工具,以及用户命令适配器。这些全是**产品**包。
一个压缩compaction能力家族参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)Service Definition、摘要提供方、无模型工具结果修剪配套工具,以及人类命令 Consumer。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
@@ -11,4 +11,6 @@
| [`compact-tool-result-prune/`](compact-tool-result-prune/README.md) | 可选的无模型工具结果修剪 | `ctx.toolResultPrune` |
| [`command-compact/`](command-compact/README.md) | 用户压缩命令 | 注册到 `ctx.commands` |
后端、可选修剪器和用户命令通过该 seam 组合token 测量仍是独立的 LLM大语言模型家族服务。[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)负责依赖关系的设计原理。
后端、可选修剪器和用户命令通过该 seam 组合token 测量仍是独立的 LLM大语言模型家族服务。[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 负责说明依赖关系的设计依据。
子系统参考——`compact/*` 事件、`CompactionResult`、服务、修剪结果——见 [docs/subsystems/compaction.md](../../docs/subsystems/compaction.md)seam 有意依赖 `dsh-session`/`dsh-llm` 的决定记录在[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。

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/command-compact/README.md
README.md: 54f341e39447a423964b7d7435cfb638857eda6e
README.zh.md: d4a122b8a19cdf907212ad019b2528ae52d03886
README.md: 5af39e82928d64c11474199b6b30058e9d289f45
README.zh.md: 70fac7ba495ae57757fbe73bd5c61951dd0c2fe9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions.
Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../interaction/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions.
## Command contract

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩compaction控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现并执行它,无需模型轮次。[排队手动压缩 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩compaction控制。该插件通过 [`ctx.commands`](../../interaction/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现并执行它,无需模型轮次。[排队手动压缩 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
## 命令
## 命令约
| 输入 | 结果 |
|---|---|

View File

@@ -63,7 +63,7 @@ async function executeCompact(
return { kind: 'error', text: USAGE }
}
try {
const result = await ctx.compact.compactNow(invocation.agent, invocation.signal)
const result = await ctx.compact.compactNow(invocation.agent, invocation.signal, invocation.commandId)
if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
return {
kind: 'success',

View File

@@ -4,6 +4,7 @@ import Loader from '@cordisjs/plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands'
import {
CompactionId,
CompactService,
ManualCompactionError,
type CompactAgentContext,
@@ -14,7 +15,10 @@ import {
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
const COMPACTION_ID = CompactionId('command-compact-test')
const RESULT: CompactionResult = {
compactionId: COMPACTION_ID,
startSeq: 1,
summarySeq: 2,
endSeq: 3,
@@ -45,18 +49,28 @@ class StubCompactService extends CompactService {
override compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
sourceCommandId?: Parameters<CompactService['compactNow']>[2],
): Promise<CompactionResult | null> {
this.calls.push({ agent, signal })
if (this.operation !== undefined) return this.operation()
return this.failure === undefined
? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result))
? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId))
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
: Promise.reject(this.failure)
}
private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult {
agent.session.append('compact/start', { turn: null })
private appendResult(
agent: ManualCompactAgentContext,
result: CompactionResult,
sourceCommandId: Parameters<CompactService['compactNow']>[2],
): CompactionResult {
const provenance = {
compactionId: result.compactionId,
...sourceCommandId === undefined ? {} : { sourceCommandId },
}
agent.session.append('compact/start', { ...provenance, turn: null })
agent.session.append('compact/summary', {
...provenance,
summary: result.summary,
shadowedRange: result.shadowedRange,
shadowedSeqs: result.shadowedSeqs,
@@ -64,8 +78,8 @@ class StubCompactService extends CompactService {
provider: 'command-test',
model: 'command-test',
})
agent.session.append('compact/end', { turn: null })
return result
agent.session.append('compact/end', { ...provenance, turn: null })
return { ...result, ...provenance }
}
}

View File

@@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import {
CompactionId,
CompactService,
type CompactAgentContext,
type CompactionResult,
@@ -18,7 +19,10 @@ import {
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
const COMPACTION_ID = CompactionId('loader-command-compact-test')
const RESULT: CompactionResult = {
compactionId: COMPACTION_ID,
startSeq: 1,
summarySeq: 2,
endSeq: 3,
@@ -44,9 +48,15 @@ class LoaderCompactService extends CompactService {
override compactNow(
agent: ManualCompactAgentContext,
_signal: AbortSignal,
sourceCommandId?: Parameters<CompactService['compactNow']>[2],
): Promise<CompactionResult | null> {
agent.session.append('compact/start', { turn: null })
const provenance = {
compactionId: RESULT.compactionId,
...sourceCommandId === undefined ? {} : { sourceCommandId },
}
agent.session.append('compact/start', { ...provenance, turn: null })
agent.session.append('compact/summary', {
...provenance,
summary: RESULT.summary,
shadowedRange: RESULT.shadowedRange,
shadowedSeqs: RESULT.shadowedSeqs,
@@ -54,8 +64,8 @@ class LoaderCompactService extends CompactService {
provider: 'loader-test',
model: 'loader-test',
})
agent.session.append('compact/end', { turn: null })
return Promise.resolve(RESULT)
agent.session.append('compact/end', { ...provenance, turn: null })
return Promise.resolve({ ...RESULT, ...provenance })
}
}
@@ -132,11 +142,17 @@ describe('command-compact real Loader composition', () => {
},
{
type: 'compact/start',
data: { turn: null },
data: {
compactionId: COMPACTION_ID,
sourceCommandId: execution.commandId,
turn: null,
},
},
{
type: 'compact/summary',
data: {
compactionId: COMPACTION_ID,
sourceCommandId: execution.commandId,
summary: RESULT.summary,
shadowedRange: RESULT.shadowedRange,
shadowedSeqs: RESULT.shadowedSeqs,
@@ -147,7 +163,11 @@ describe('command-compact real Loader composition', () => {
},
{
type: 'compact/end',
data: { turn: null },
data: {
compactionId: COMPACTION_ID,
sourceCommandId: execution.commandId,
turn: null,
},
},
{
type: 'command/done',

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../../ui/commands"
"path": "../../interaction/commands"
},
{
"path": "../compact"

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-basic/README.md
README.md: 9bea77045c60755ed4280d3239472df456903cc8
README.zh.md: eef454c10b40081655c287815b65f34ae0c5ae47
README.md: 97c71cafe1eba7dd29c903535b378439586e807e
README.zh.md: 55a0080fe3c08e5559d737d46f57e23645a52cad

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`).
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` Service Definition with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`).
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
This package owns the Service provider role of the compaction capability — see the [Service Definition package](../compact/README.md) for its contract and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
## What it owns
@@ -15,13 +15,13 @@ This backend owns the compaction policy:
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; the serial `agent/pre-step` listener checks pressure before request derivation, while canonical provider overflow enters through `agent/request-error` and authorizes retry only after durable surface progress. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` extension point. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the `compact/summary` event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends `compact/summary` plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; the serial `agent/pre-step` listener checks pressure before request derivation, while canonical provider overflow enters through `agent/request-error` and authorizes retry only after durable surface progress. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compact/summary`.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, cited source events, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)
@@ -106,7 +106,7 @@ Replacing rather than append-only. Each checkpoint invalidates reuse from the fi
#### What the model sees
The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages, including image references, that the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored.
The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored.
##### Compaction instruction (final user message)

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
**基础压缩compaction后端**`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV Cache可在 `llm/stream` 处拦截)。
**基础压缩compaction后端**`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` Service Definition,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV Cache可在 `llm/stream` 处拦截)。
这是压缩能力的实现层。seam 见 [接口](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
本包承担压缩能力的 Service provider 角色;其约定见 [Service Definition ](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
## 拥有的职责
@@ -15,13 +15,13 @@
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则对已剪枝的表层进行摘要。低于压力的步骤检查绝不剪枝。
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 将切分点调整到工具调用/结果配对平衡的位置。轮次边界不会保护失控轮次内的旧步骤。尚未闭合且不可分的尾部会在闭合前拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息(包括图片引用),并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`适配器可将其作为请求归因转发DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`但不会触碰模型可见的请求体。只有返回的文本会进入检查点推理reasoning和工具调用都会被排除以免泄露私有推理或产生遗留调用图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息(包括图片引用),并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`适配器可将其作为请求归因转发DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`但不会触碰模型可见的请求体。只有返回的文本会进入检查点推理reasoning和工具调用都会被排除以免泄露私有推理或产生遗留调用图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在 `compact/summary` 事件上,后续自动周期会合并之前的检查点。
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加 `compact/summary` 和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }``llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,且必须提供完整的 `rawOutput`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、被引用的源事件、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }``llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,且必须提供完整的 `rawOutput`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
## 配置(`BasicCompactConfig`
@@ -106,7 +106,7 @@ This is an automatically generated checkpoint condensing an earlier span of the
#### 模型看到的内容
摘要模型会接收逐字回放的会话:与上次已路由请求为已遮蔽区域发送的相同系统提示词、工具 schema 与消息(包括图片引用),后面跟随一条最终 user 消息,即下方压缩指令。会话模型绝不会看到该私有请求或其推理;只有返回文本会被存储。
摘要模型会接收逐字回放的会话:与上次已路由请求为已遮蔽区域发送的相同系统提示词、工具 schema 与消息,后面跟随一条最终 user 消息,即下方压缩指令。会话模型绝不会看到该私有请求或其推理;只有返回文本会被存储。
##### 压缩指令(最终 user 消息)

View File

@@ -27,6 +27,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",

View File

@@ -13,6 +13,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
import {
@@ -93,7 +94,7 @@ const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
/**
* Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
* retention, provenance, and summary-convergence pricing.
* retention, cited source events, and summary-convergence pricing.
*
* `summarize()` is the sole subclass customization hook; the replay and durable
* mutation strategy stays fixed so every pricing decision uses the singleton
@@ -230,7 +231,7 @@ export class BasicCompactService extends CompactService {
* @param input - replayed conversation prefix (system, tools, and leading messages) to condense.
* @param agent - supplies routed-model history, fallback model, and session id.
* @param signal - optional cancellation forwarded to the adapter.
* @returns safe text summary blocks and exact auxiliary-call provenance.
* @returns safe text summary blocks and the exact auxiliary call envelope and output.
*/
protected async summarize(
input: SummarizationInput,
@@ -361,9 +362,14 @@ export class BasicCompactService extends CompactService {
* resolve only after its standalone marker pair is durably checkpointed.
* @param agent - idle agent whose next-turn admission this call reserves.
* @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for presentation correlation.
* @returns the committed result, or `null` when no safe useful range exists.
*/
override compactNow(agent: Agent, signal: AbortSignal): Promise<CompactionResult | null> {
override compactNow(
agent: Agent,
signal: AbortSignal,
sourceCommandId?: CommandId,
): Promise<CompactionResult | null> {
signal.throwIfAborted()
try {
return agent.runMaintenance(async (agentSignal) => {
@@ -385,6 +391,7 @@ export class BasicCompactService extends CompactService {
{
owner: null,
stability: 'selected-span',
...sourceCommandId === undefined ? {} : { sourceCommandId },
flush: async () => {
await this.ctx.sessions.flush(agent.session)
},

View File

@@ -5,14 +5,17 @@
* @module @deepseek-ai/dsh-compact-basic/region
*/
import { randomUUID } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import {
COMPACT_CHECKPOINT_SOURCE,
CompactionId,
ManualCompactionError,
compactCheckpointSource,
toolPairingBalancedAfter,
toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
@@ -54,6 +57,8 @@ interface CompactionTransactionOptions {
readonly stability: 'whole-surface' | 'selected-span'
/** Optional durability checkpoint after a successfully closed bracket. */
readonly flush?: () => Promise<void>
/** Manual command that initiated this transaction, when present. */
readonly sourceCommandId?: CommandId
}
interface CompactionEntryState {
@@ -175,7 +180,13 @@ export async function compactSurfaceRegion(
owner = entryState.openTurn
}
const startEvent = session.append('compact/start', { turn: owner })
const compactionId = CompactionId(randomUUID())
const lifecycle = {
compactionId,
...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId },
turn: owner,
}
const startEvent = session.append('compact/start', lifecycle)
const assertStable: StabilityCheck = options.stability === 'whole-surface'
? assertWholeSurfaceUnchanged
: assertSelectedSpanStable
@@ -188,13 +199,20 @@ export async function compactSurfaceRegion(
try {
const prepared = prepareCompaction(dependencies, session, selection)
const summarized = await summarizeCompaction(dependencies, prepared, agent, signal)
const summarized = await summarizeCompaction(
dependencies,
prepared,
agent,
compactionId,
options.sourceCommandId,
signal,
)
if (options.owner === null) signal?.throwIfAborted()
assertStable(dependencies, session, summarized)
stage = 'commit'
const pending = commitCompactionBody(session, startEvent, summarized)
closing = true
const endEvent = session.append('compact/end', { turn: owner })
const endEvent = session.append('compact/end', lifecycle)
closed = true
result = completeCompaction(pending, endEvent)
} catch (error: unknown) {
@@ -202,7 +220,7 @@ export async function compactSurfaceRegion(
if (!closing) {
closing = true
try {
session.append('compact/end', { turn: owner, error: errorChain(error) })
session.append('compact/end', { ...lifecycle, error: errorChain(error) })
closed = true
} catch (closeError: unknown) {
failure = { error: closeError, stage: 'commit' }
@@ -343,12 +361,14 @@ async function summarizeCompaction(
dependencies: RegionDependencies,
prepared: PreparedCompaction,
agent: Agent,
compactionId: CompactionResult['compactionId'],
sourceCommandId: CommandId | undefined,
signal?: AbortSignal,
): Promise<SummarizedCompaction> {
const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
const checkpointMessage = createUserMessage({
content: frameSummary(summaryResult.summary),
source: COMPACT_CHECKPOINT_SOURCE,
source: compactCheckpointSource(compactionId, sourceCommandId),
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
@@ -403,7 +423,7 @@ function assertSelectedSpanStable(
}
}
/** Append one already-summarized provenance and replacement body without yielding. */
/** Append one completed summary record and replacement body without yielding. */
function commitCompactionBody(
session: Session,
startEvent: SessionEvent<'compact/start'>,
@@ -425,6 +445,10 @@ function commitCompactionBody(
? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
: summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
const summaryEvent = session.append('compact/summary', {
compactionId: startEvent.data.compactionId,
...startEvent.data.sourceCommandId === undefined
? {}
: { sourceCommandId: startEvent.data.sourceCommandId },
summary,
...callProvenance,
shadowedRange: { start, end },
@@ -440,6 +464,10 @@ function commitCompactionBody(
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
return {
compactionId: startEvent.data.compactionId,
...startEvent.data.sourceCommandId === undefined
? {}
: { sourceCommandId: startEvent.data.sourceCommandId },
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
summary,

View File

@@ -84,7 +84,7 @@ export interface SummarizationInput {
readonly messages: readonly Message[]
}
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
/** Safe summary content plus the exact auxiliary call envelope recorded with it. */
export type SummaryResult = {
summary: ContentBlock[]
provider: string
@@ -116,7 +116,7 @@ export type SummaryResult = {
* @param input - replayed conversation prefix (system, tools, and leading messages) to condense.
* @param agent - supplies routed-model history, fallback model, and session id.
* @param signal - optional cancellation forwarded to the adapter.
* @returns safe text-only summary blocks and exact call provenance.
* @returns safe text-only summary blocks and the exact call envelope and output.
*/
export async function summarizeWithLlm(
ctx: Context,

View File

@@ -5,7 +5,7 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import {
resolveCompactSpec,
resolveConfig,
@@ -839,7 +839,7 @@ describe('optional model-free tool-result pruning', () => {
})
describe('compaction region transaction', () => {
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
it('lands a framed, replayable checkpoint with exact source seqs and token price', async () => {
const compact = service()
compact.rawOutput = [
{ type: 'reasoning', text: 'private compact thought' },
@@ -946,7 +946,10 @@ describe('compaction region transaction', () => {
)).rejects.toThrow(/no open turn/)
const locked = conversation(1)
locked.append('compact/start', { turn: 2 })
locked.append('compact/start', {
compactionId: CompactionId('locked-compaction'),
turn: 2,
})
const lockedNodes = locked.surface.nodes
await expect(compact.compactRegion(
lockedNodes[0]!,
@@ -1673,6 +1676,7 @@ describe('automatic listener and loader composition', () => {
const compact = new TestCompactService(ctx)
const session = conversation(2)
const fakeResult: CompactionResult = {
compactionId: CompactionId('fake-compaction'),
startSeq: 1,
summarySeq: 2,
endSeq: 3,

View File

@@ -3,13 +3,14 @@ import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import {
createAssistantMessage,
@@ -23,7 +24,7 @@ import type {
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -394,22 +395,35 @@ describe('compactNow transaction and failure classification', () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2, 7)
const agent = fakeAgent(session, () => () => undefined)
const commandId = CommandId('manual-compact-command')
const result = await compact.compactNow(agent, SIGNAL)
const result = await compact.compactNow(agent, SIGNAL, commandId)
expect(result).not.toBeNull()
expect(result?.sourceCommandId).toBe(commandId)
expect(flushes()).toBe(1)
expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
expect(session.events.findLast(event => event.type === 'compact/start')?.data)
.toEqual({ turn: null })
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toEqual({ turn: null })
const start = session.events.findLast(event => event.type === 'compact/start')
const summaryEvent = session.events.findLast(event => event.type === 'compact/summary')
const checkpoint = session.events.findLast(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source),
)
const end = session.events.findLast(event => event.type === 'compact/end')
const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId }
expect(start?.data).toEqual({ ...correlated, turn: null })
expect(summaryEvent?.data.sourceCommandId).toBe(commandId)
expect(checkpoint?.data.source).toMatchObject(correlated)
expect(end?.data).toEqual({ ...correlated, turn: null })
})
it('reports a live unmatched bracket as busy without summarizing', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('compact/start', { turn: null })
session.append('compact/start', {
compactionId: CompactionId('live-manual-compaction'),
turn: null,
})
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(() => compact.compactNow(agent, SIGNAL))
@@ -421,7 +435,10 @@ describe('compactNow transaction and failure classification', () => {
it('ignores an unmatched bracket inherited before a later end-seed marker', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
original.append('compact/start', {
compactionId: CompactionId('stale-manual-compaction'),
turn: null,
})
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
const orphan = reloaded.events.find(event => event.type === 'compact/start')
@@ -435,7 +452,10 @@ describe('compactNow transaction and failure classification', () => {
it('scans a stale orphan independently of later repaired turn state', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
original.append('compact/start', {
compactionId: CompactionId('reloaded-manual-compaction'),
turn: null,
})
original.append('turn/start', { turn: 3 })
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
@@ -618,7 +638,7 @@ describe('compactNow transaction and failure classification', () => {
const agent = fakeAgent(session, () => () => { released += 1 })
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/summary') throw new Error('provenance rejected')
if (type === 'compact/summary') throw new Error('summary record rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
@@ -627,7 +647,7 @@ describe('compactNow transaction and failure classification', () => {
expect(error.code).toBe('commit')
expect(released).toBe(1)
const end = session.events.findLast(event => event.type === 'compact/end')
expect(end?.type === 'compact/end' && end.data.error).toContain('provenance rejected')
expect(end?.type === 'compact/end' && end.data.error).toContain('summary record rejected')
expect(end?.type === 'compact/end' && end.data.turn).toBeNull()
})
@@ -637,14 +657,14 @@ describe('compactNow transaction and failure classification', () => {
const agent = fakeAgent(session, () => () => undefined)
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/summary') throw new Error('provenance rejected')
if (type === 'compact/summary') throw new Error('summary record rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('provenance rejected')
expect(causeOf(error).message).toBe('summary record rejected')
vi.restoreAllMocks()
})
@@ -664,7 +684,7 @@ describe('compactNow transaction and failure classification', () => {
expect(result).not.toBeNull()
expect(session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(session.events.find(event => event.type === 'compact/start')?.data)
.toEqual({ turn: null })
.toEqual({ compactionId: result?.compactionId, turn: null })
})
it('classifies a durability failure after the standalone bracket committed', async () => {
@@ -676,8 +696,9 @@ describe('compactNow transaction and failure classification', () => {
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence')
vi.restoreAllMocks()
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toEqual({ turn: null })
const start = session.events.findLast(event => event.type === 'compact/start')
const end = session.events.findLast(event => event.type === 'compact/end')
expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null })
})
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {

View File

@@ -27,6 +27,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../interaction/commands"
},
{
"path": "../compact"
},

View File

@@ -124,7 +124,7 @@ export class ToolResultPruneService extends Service {
/**
* Prune every over-budget tool result from one stable current-surface snapshot.
* Each replacement preserves the complete event data except for `content`,
* points at the shadowed node for durable provenance and replay, and is
* cites the shadowed node so replay can recover the replacement input, and is
* immediately preceded by a `compact/prune` shadow-price event pricing the
* shadowed node through the injected token meter, so pure consumers can
* subtract it without per-node state.

View File

@@ -17,7 +17,7 @@ export interface ResolvedConfig {
readonly tailChars: number
}
/** Provenance and size accounting for one landed surface replacement. */
/** Cited source event and size accounting for one landed surface replacement. */
export interface PrunedEntry {
/** Full-fidelity tool-result event shadowed by the replacement. */
readonly originalSeq: number

View File

@@ -159,7 +159,7 @@ describe('ToolResultPruneService content transform', () => {
})
describe('ToolResultPruneService session transaction', () => {
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
it('prunes a stable snapshot, preserves all data, and cites the replaced result', () => {
const session = Session.create(SessionId('preserve'))
const originalSeq = appendToolStep(session, 1, 'one', [{
type: 'text',

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: cd2404cda8d8702ca0400d3f23d9e1fe041495a1
README.zh.md: ed6ccc630118dc1bd6d6ea761beefafadb4bd48f
README.md: eac4339da76352bc9468488d0828a0b90a993d48
README.zh.md: 4aec6123169a7c88a36da647202e7462fbf94564

View File

@@ -2,29 +2,29 @@
English | [中文](README.zh.md)
The **compaction seam**: an abstract `CompactService` (`ctx.compact`) defining WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.
The **`CompactService`** (`ctx.compact`) defines WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.
This package is the interface tier of the compaction capability, split so each concern evolves (and swaps) independently:
This package owns the Service Definition role of the compaction capability, split so each role evolves (and swaps) independently:
| Package | Role |
|---|---|
| `@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-command-compact` | the human `/compact` command over `ctx.compact.compactNow()` |
| `@deepseek-ai/dsh-compact` (this) | Service Definition: abstract service + `compact/*` events + `CompactionResult` + correlated checkpoint-source constructor + tool-pairing boundary helpers |
| `@deepseek-ai/dsh-compact-basic` | Service provider: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-command-compact` | Consumer: 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).
Unlike the bash seam, this Service Definition 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 "Service Definition 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`)
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.
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 Service Definition.
| 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. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source comes from `compactCheckpointSource(compactionId)`. **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).
`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/subsystems/compaction.md#compactionresult).
`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`.
@@ -32,7 +32,7 @@ All three operations are **abstract** — the backend owns trigger policy, reten
## Tool-pairing boundaries
The interface exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-entry count. An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
@@ -42,8 +42,8 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
1. appends `compact/start` (log-only) — acquires the lock,
2. summarizes the range,
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope,
4. appends a single `user/message` with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
3. appends `compact/summary` (log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope,
4. appends a single `user/message` with `source: compactCheckpointSource(compactionId, sourceCommandId?)` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
5. appends `compact/end` (log-only) — releases the lock.
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.
@@ -64,11 +64,11 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
## Implementing a backend
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.
Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend creates its replacement user message source with `compactCheckpointSource(compactionId, sourceCommandId?)`; the required `compactionId` correlates the checkpoint with its `compact/*` transaction, while `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`)
`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../ui/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there.
`compactCheckpointSource()`, `CompactCheckpointSource`, and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The constructor requires the owning `CompactionId`, preventing backends from writing an uncorrelated marker that the package invariant must reject. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to the leaf's source type, so renaming the plugin id there is a compile error here.
## Model Experience
@@ -80,7 +80,7 @@ A successful implementation replaces an older surface range with one user-role s
#### Token effect
Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
Zero direct tokens from this Service Definition. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
#### KV Cache effect

View File

@@ -2,29 +2,29 @@
[English](README.md) | 中文
**压缩compaction seam**:抽象 `CompactService``ctx.compact`)定义压缩做什么,即判定历史记录是否过大,并将较早范围摘要为单个表层节点,但不规定如何实现。
**`CompactService`**`ctx.compact`)定义压缩做什么,即判定历史记录是否过大,并将较早范围摘要为单个表层节点,但不规定如何实现。
这个包是压缩能力的接口层,因此各项职责均可独立演进,也可独立替换:
本包承担压缩能力的 Service Definition 角色,因此各角色均可独立演进,也可独立替换:
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper |
| `@deepseek-ai/dsh-compact-basic` | 后端`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-command-compact` | 面向用户`/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
| `@deepseek-ai/dsh-compact`(本包) | Service Definition:抽象服务 + `compact/*` 事件 + `CompactionResult` + 关联检查点源构造函数 + 工具配对边界 helper |
| `@deepseek-ai/dsh-compact-basic` | Service provider`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-command-compact` | Consumer面向人类`/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session``@deepseek-ai/dsh-llm`约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的并记录在 [压缩能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
与 bash seam 不同,该 Service Definition 依赖 `@deepseek-ai/dsh-session``@deepseek-ai/dsh-llm`。约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「Service Definition 只依赖 cordis」指引的偏离是有意的并记录在 [压缩能力 seam Agent Note](../../../.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),而非本 Service Definition 的一部分。
| 成员 | 语义 |
|---|---|
| `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 顺序。 |
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq`agent.session` 摘要为单个替换节点,其源 `compactCheckpointSource(compactionId)` 创建。如果压缩已在进行、`start``end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/subsystems/compaction.md#compactionresult)。
`compactIfNeeded``compactNow` 必须传入 `signal``compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose资源释放会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次并标记 `turn: null`
@@ -32,18 +32,18 @@
## 工具配对边界
接口导出 `toolPairingBalancedBefore(session, seq)``toolPairingBalancedAfter(session, seq)`,用于对齐和验证压缩边界。安全边界不会被尚未回答的 assistant 工具调用跨越。每个 helper 都会验证给定事件 seq 位于当前表层,并根据按表层顺序缓存的各切分点配对状态返回结果。
Service Definition 导出 `toolPairingBalancedBefore(session, seq)``toolPairingBalancedAfter(session, seq)`,用于对齐和验证压缩边界。安全边界不会被尚未回答的 assistant 工具调用跨越。每个 helper 都会验证给定事件 seq 位于当前表层,并根据按表层顺序缓存的各切分点配对状态返回结果。
每个会话的私有 cache 以 `session.surface.replaceGeneration` 和已处理表层条目数为 key。generation 未变时只需将尚未处理的尾部条目纳入累计结果仅向日志追加、但未新增表层条目时不会读取事件。replace generation 变化时则会重建当前成员关系与配对状态。事件 seq 缺失以及 `tool/result` 没有对应的先前未闭合调用,均会被视为表层状态损坏并遭拒绝。
## 表层
## 表层约
`SurfaceEventType` 是封闭联合:只有 `user/message``assistant/message``tool/result` 可以携带 `surfaceOp`。因此 `compact/*` 事件**不能**出现在表层上。成功压缩改为:
1. 追加 `compact/start`(仅日志):获取锁;
2. 摘要该范围;
3. 追加 `compact/summary`(仅日志):溯源信息包括摘要、范围、已遮蔽 seq、token 数与提供方/模型调用 envelope
4. 追加单个 `user/message`,其携带 `source: COMPACT_CHECKPOINT_SOURCE` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**
3. 追加 `compact/summary`(仅日志),其中记录摘要、范围、已遮蔽 seq、token 数与提供方/模型调用 envelope
4. 追加单个 `user/message`,其携带 `source: compactCheckpointSource(compactionId, sourceCommandId?)` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**
5. 追加 `compact/end`(仅日志):释放锁。
表层变更(第 4 步)位于锁的起止范围**内**`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start``compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`
@@ -64,11 +64,11 @@
## 实现后端
继承 `CompactService`,实现 `compactIfNeeded``compactNow``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
继承 `CompactService`,实现 `compactIfNeeded``compactNow``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换 user 消息的源;必填的 `compactionId` 将检查点与对应 `compact/*` 事务关联,而 `isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`
`COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强即 [`dsh-commands/brand`](../../ui/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout)。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。
`compactCheckpointSource()``CompactCheckpointSource``isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。构造函数要求传入所属 `CompactionId`,防止后端写入缺少关联关系、必然被包不变量拒绝的标记。该叶子不导入 cordis、也不声明任何模块增强即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout)。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子的源类型上,因此在此处改插件 id 会让那边编译失败。
## 模型体验
@@ -80,7 +80,7 @@
#### Token 影响
接口不会直接产生 token。后端用一份摘要换取多个原本保留的历史 token并保持近期尾部不变。
Service Definition 不会直接产生 token。后端用一份摘要换取多个原本保留的历史 token并保持近期尾部不变。
#### KV Cache 影响
@@ -89,5 +89,5 @@
## 已知限制与暂缓事项
- **面向用户的命令,而非模型工具**`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具。
- **部分单元溢出不在约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
- **部分单元溢出不在约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。

View File

@@ -15,6 +15,10 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./checkpoint": {
"types": "./lib/types/checkpoint.d.ts",
"default": "./lib/types/checkpoint.js"
@@ -30,12 +34,16 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -0,0 +1,13 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable identity shared by one compact start/summary/checkpoint/end transaction. */
export type CompactionId = Branded<'CompactionId'>
/**
* Brand an implementation-minted compaction identity.
* @param id - opaque transaction identity.
* @returns the same string, branded; no validation is performed.
*/
export function CompactionId(id: string): CompactionId {
return id as CompactionId
}

View File

@@ -1,21 +1,45 @@
/**
* The compaction seam's canonical checkpoint source: the plugin marker every
* backend stamps on the replacement user message that lands a checkpoint, plus
* the predicate that recognizes it.
* Compaction checkpoint provenance: the correlated source constructor and type
* every backend uses for its replacement user message, plus the predicate that
* recognizes persisted checkpoints.
*
* The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports both of
* these; this module is a pure value/predicate outlet (no cordis imports, no
* module augmentation) so client and wire programs can name the checkpoint
* source without loading the host plugin's Context merges — the
* The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports these
* contracts; this module is a pure type/value/predicate outlet (no cordis
* imports, no module augmentation) so client and wire programs can name the
* checkpoint source without loading the host plugin's Context merges — the
* `dsh-commands/brand` shape.
*
* @module @deepseek-ai/dsh-compact/checkpoint
*/
import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
/** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/** Message provenance carried by a concrete compaction checkpoint. */
export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & {
readonly compactionId: CompactionId
readonly sourceCommandId?: CommandId
}
/**
* Create checkpoint provenance correlated with one compaction transaction.
* @param compactionId - owning compaction identity.
* @param sourceCommandId - initiating manual command, when present.
* @returns immutable checkpoint source.
*/
export function compactCheckpointSource(
compactionId: CompactionId,
sourceCommandId?: CommandId,
): CompactCheckpointSource {
return Object.freeze({
...COMPACT_CHECKPOINT_MARKER,
compactionId,
...sourceCommandId === undefined ? {} : { sourceCommandId },
})
}
/**
* Test whether a persisted message source identifies a compaction checkpoint.
@@ -23,5 +47,5 @@ export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin:
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin
}

View File

@@ -1,5 +1,5 @@
/**
* Compaction service seam (`ctx.compact`): implementations decide when to
* Compaction Service Definition (`ctx.compact`): providers decide when to
* compact and replace a history range with one summary node by subclassing
* {@link CompactService}. This interface necessarily depends on session and LLM
* vocabulary; the rationale is in the
@@ -9,14 +9,17 @@
import { Context, Service } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { CompactionId } from './brand.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
// The checkpoint source and its predicate are declared on the cordis-free
// The checkpoint source constructor and predicate are declared on the cordis-free
// `./checkpoint` leaf so client and wire programs can name them without this
// root's Context merge; the root stays the host-side entry point for both.
export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts'
export { compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts'
export type { CompactCheckpointSource } from './checkpoint.ts'
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
@@ -86,9 +89,9 @@ declare module 'cordis' {
* and summarization, and may consume a separate measurement service. A
* successful run replaces the selected surface span with one summary node and
* prevents concurrent compaction of the same session. The replacement user
* message uses {@link COMPACT_CHECKPOINT_SOURCE} so consumers recognize it
* independently of the backend. Load one implementation per context as
* `ctx.compact`.
* message uses {@link compactCheckpointSource} with the transaction identity
* so consumers recognize and correlate it independently of the backend. Load
* one implementation per context as `ctx.compact`.
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {
@@ -126,6 +129,7 @@ export abstract class CompactService extends Service {
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for a manual compaction.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, agent-cancellation,
* changed-span, summarization/shrink, commit-stage, or persistence failures;
@@ -135,6 +139,7 @@ export abstract class CompactService extends Service {
abstract compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
sourceCommandId?: CommandId,
): Promise<CompactionResult | null>
/**
@@ -144,7 +149,8 @@ export abstract class CompactService extends Service {
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges. The target session is `agent.session`.
* Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.
* Its replacement user message must use {@link compactCheckpointSource} with
* the transaction's `CompactionId`.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*

View File

@@ -1,8 +1,12 @@
/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */
import type { Context } from 'cordis'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { CompactionId } from './brand.ts'
import { isCompactCheckpointSource } from './checkpoint.ts'
import type { CompactCheckpointSource } from './checkpoint.ts'
import type {} from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-compact'
@@ -13,6 +17,8 @@ export const name = 'compact-invariant'
export const inject = ['invariants']
interface CompactionTrace {
compactionId: CompactionId
sourceCommandId: string | undefined
startSeq: number
turn: number | null
summarized: boolean
@@ -24,11 +30,48 @@ interface SessionTrace {
}
type CompactionTransition =
| { kind: 'start'; startSeq: number; turn: number | null }
| { kind: 'summary'; startSeq: number; turn: number | null }
| { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null }
| { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null }
| { kind: 'end' }
| { kind: 'end-seed' }
/** Require a durable opaque identity to be a non-empty string. */
function validateId(value: unknown, label: string, fail: InvariantFailure): asserts value is string {
if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`)
}
/** Keep the optional initiating command identity stable across one transaction. */
function validateSourceCommandId(
eventType: string,
value: unknown,
expected: string | undefined,
fail: InvariantFailure,
): void {
if (value !== undefined) validateId(value, `${eventType} sourceCommandId`, fail)
if (value !== expected) {
fail(`${eventType} sourceCommandId ${String(value)} does not match compact/start sourceCommandId ${String(expected)}`)
}
}
/** Validate one replacement checkpoint against its open compaction transaction. */
function validateCheckpoint(
trace: SessionTrace,
event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const source = event.data.source as typeof event.data.source & Partial<CompactCheckpointSource>
validateId(source.compactionId, 'compaction checkpoint compactionId', fail)
if (source.sourceCommandId !== undefined) {
validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail)
}
const open = trace.compaction
if (open === undefined) fail('compaction checkpoint has no matching compact/start')
if (source.compactionId !== open.compactionId) {
fail(`compaction checkpoint id ${source.compactionId} does not match compact/start id ${open.compactionId}`)
}
validateSourceCommandId('compaction checkpoint', source.sourceCommandId, open.sourceCommandId, fail)
}
/** Compaction starts still unmatched when a later seed boundary made them stale. */
function inheritedOrphanStartSeqs(
events: readonly SessionEvent[],
@@ -99,20 +142,44 @@ function validateCompactionEvent(
fail: InvariantFailure,
): CompactionTransition | undefined {
if (event.type === 'session/end-seed') return { kind: 'end-seed' }
if (event.type === 'user/message'
&& isReplacementSurfaceEvent(event)
&& isCompactCheckpointSource(event.data.source)) {
validateCheckpoint(trace, event, fail)
return undefined
}
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') {
return undefined
}
const open = trace.compaction
if (event.type === 'compact/start') {
validateId(event.data.compactionId, 'compact/start compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compact/start sourceCommandId', fail)
}
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', startSeq: event.seq, turn: event.data.turn }
return {
kind: 'start',
compactionId: event.data.compactionId,
sourceCommandId: event.data.sourceCommandId,
startSeq: event.seq,
turn: event.data.turn,
}
}
if (event.type === 'compact/summary') {
validateId(event.data.compactionId, 'compact/summary compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compact/summary sourceCommandId', fail)
}
if (open === undefined) fail('compact/summary has no matching compact/start')
if (event.data.compactionId !== open.compactionId) {
fail(`compact/summary id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`)
}
validateSourceCommandId('compact/summary', event.data.sourceCommandId, open.sourceCommandId, fail)
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (open.summarized) fail('compact/summary repeated within one compaction')
const seqs = event.data.shadowedSeqs
@@ -123,9 +190,23 @@ function validateCompactionEvent(
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
fail('compact/summary shadowedTokenCount must be a non-negative safe integer')
}
return { kind: 'summary', startSeq: open.startSeq, turn: open.turn }
return {
kind: 'summary',
compactionId: open.compactionId,
sourceCommandId: open.sourceCommandId,
startSeq: open.startSeq,
turn: open.turn,
}
}
validateId(event.data.compactionId, 'compact/end compactionId', fail)
if (event.data.sourceCommandId !== undefined) {
validateId(event.data.sourceCommandId, 'compact/end sourceCommandId', fail)
}
if (open === undefined) fail('compact/end has no matching compact/start')
if (event.data.compactionId !== open.compactionId) {
fail(`compact/end id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`)
}
validateSourceCommandId('compact/end', event.data.sourceCommandId, open.sourceCommandId, fail)
if (event.data.turn !== open.turn) {
fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`)
}
@@ -142,6 +223,8 @@ function applyCompactionTransition(
): CompactionTrace | undefined {
if (transition.kind === 'start') {
return {
compactionId: transition.compactionId,
sourceCommandId: transition.sourceCommandId,
startSeq: transition.startSeq,
turn: transition.turn,
summarized: false,
@@ -149,6 +232,8 @@ function applyCompactionTransition(
}
if (transition.kind === 'summary') {
return {
compactionId: transition.compactionId,
sourceCommandId: transition.sourceCommandId,
startSeq: transition.startSeq,
turn: transition.turn,
summarized: true,

View File

@@ -1,6 +1,6 @@
/**
* Compaction vocabulary: the result type and the `compact/*` session events.
* Those declaration-merged events are log-only lock/provenance markers, not
* Those declaration-merged events record the lock and summary inputs without entering the surface, so they are not
* surface events; a separate replacement `user/message` carries the summary.
* Backend packages own configuration and retention policy; see
* `.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md`.
@@ -8,17 +8,21 @@
*/
import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CompactionId } from './brand.ts'
declare module '@deepseek-ai/dsh-session' {
export type { CompactionId }
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* 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 }
'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null }
/**
* Provenance record of a completed summarization — log-only, no surfaceOp.
* Completed summary, its inputs, and its model call facts — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement
* is performed by the immediately following `user/message` event that
* shadows the compacted range. That adjacency is contractual — the
@@ -27,6 +31,8 @@ declare module '@deepseek-ai/dsh-session' {
* before it (`compact/prune` documents the shared protocol).
*/
'compact/summary': {
compactionId: CompactionId
sourceCommandId?: CommandId
summary: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
@@ -62,7 +68,7 @@ declare module '@deepseek-ai/dsh-session' {
* 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 }
'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string }
/**
* Shadow price of one model-free prune replacement — log-only, no
* surfaceOp. The shared shadow-price protocol: a surface `replace` event
@@ -85,6 +91,10 @@ declare module '@deepseek-ai/dsh-session' {
/** Result of a successful compaction operation. */
export interface CompactionResult {
/** Stable identity shared by this compaction's complete durable lifecycle. */
compactionId: CompactionId
/** Human command that initiated this compaction, when it was manual. */
sourceCommandId?: CommandId
/** The seq of the appended `compact/start` event. */
startSeq: number
/** The seq of the appended `compact/summary` event. */

View File

@@ -2,8 +2,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import {
COMPACT_CHECKPOINT_SOURCE,
CompactionId,
CompactService,
compactCheckpointSource,
isCompactCheckpointSource,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
@@ -13,7 +14,7 @@ import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact'
/**
* A trivial concrete CompactService implementing the abstract contract. The
* interface package owns no algorithm — these tests exercise the seam itself:
* Service Definition package owns no algorithm — these tests exercise its contract:
* service registration, the abstract method shape, and the `compact/*` event
* declaration merge.
*/
@@ -52,9 +53,11 @@ class StubCompactService extends CompactService {
const endIndex = surface.indexOf(end)
if (startIndex < 0 || endIndex < startIndex) throw new Error('stub compact range is invalid')
const shadowedSeqs = surface.slice(startIndex, endIndex + 1)
const compactionId = CompactionId('stub-compaction')
// Minimal stub honoring the lock + log-only event contract.
const startEvent = session.append('compact/start', { turn: 0 })
const startEvent = session.append('compact/start', { compactionId, turn: 0 })
const summaryEvent = session.append('compact/summary', {
compactionId,
summary,
shadowedRange: { start, end },
shadowedSeqs,
@@ -64,13 +67,14 @@ class StubCompactService extends CompactService {
})
session.append('user/message', createUserMessage({
content: summary,
source: COMPACT_CHECKPOINT_SOURCE,
source: compactCheckpointSource(compactionId),
}), {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compact/end', { turn: 0 })
const endEvent = session.append('compact/end', { compactionId, turn: 0 })
return {
compactionId,
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
@@ -139,7 +143,8 @@ describe('CompactService seam', () => {
expect(result.shadowedSeqs).toEqual([original.seq])
const checkpoint = session.events.find(event => event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source))
expect(checkpoint?.type === 'user/message' && checkpoint.data.source).toEqual(COMPACT_CHECKPOINT_SOURCE)
expect(checkpoint?.type === 'user/message' && checkpoint.data.source)
.toEqual(compactCheckpointSource(result.compactionId))
expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false)
expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false)
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact'
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
@@ -12,7 +15,13 @@ async function setup(): Promise<Context> {
return ctx
}
const TEST_COMPACTION_ID = CompactionId('test-compaction')
const NEXT_COMPACTION_ID = CompactionId('next-test-compaction')
const TEST_COMMAND_ID = CommandId('test-command')
const NEXT_COMMAND_ID = CommandId('next-test-command')
const summary = (overrides: Record<string, unknown> = {}) => ({
compactionId: TEST_COMPACTION_ID,
summary: [{ type: 'text' as const, text: 'short' }],
shadowedRange: { start: 2, end: 4 },
shadowedSeqs: [2, 3, 4],
@@ -31,33 +40,33 @@ describe('compaction invariants', () => {
const ctx = await setup()
const success = ctx.sessions.create()
startTurn(success)
success.append('compact/start', { turn: 1 })
success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
success.append('compact/summary', summary())
success.append('compact/end', { turn: 1 })
success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 })
const failed = ctx.sessions.create()
startTurn(failed, 2)
failed.append('compact/start', { turn: 2 })
failed.append('compact/end', { turn: 2, error: 'provider failed' })
failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 })
failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, 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/start', { compactionId: TEST_COMPACTION_ID, turn: null })
success.append('compact/summary', summary())
success.append('compact/end', { turn: null })
success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null })
const failed = ctx.sessions.create()
failed.append('compact/start', { turn: null })
failed.append('compact/end', { turn: null, error: 'provider failed' })
failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, 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 = Session.create(SessionId('stale-compaction-source'))
source.append('compact/start', { turn: null })
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
seed: source.events,
})
@@ -68,8 +77,8 @@ describe('compaction invariants', () => {
await ctx.plugin(CompactInvariant)
expect(() => {
replayed.append('compact/start', { turn: null })
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
replayed.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null })
replayed.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: null, error: 'new attempt failed' })
}).not.toThrow()
})
@@ -78,7 +87,7 @@ describe('compaction invariants', () => {
await ctx.plugin(SessionStore)
const source = Session.create(SessionId('stale-numbered-compaction-source'))
startTurn(source)
source.append('compact/start', { turn: 1 })
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
seed: source.events,
})
@@ -98,7 +107,7 @@ describe('compaction invariants', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = Session.create(SessionId('stale-repaired-compaction-source'))
source.append('compact/start', { turn: null })
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
@@ -124,10 +133,10 @@ describe('compaction invariants', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = Session.create(SessionId('closed-nested-compaction-source'))
source.append('compact/start', { turn: null })
source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
source.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' })
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
seed: source.events,
})
@@ -143,10 +152,14 @@ describe('compaction invariants', () => {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1 })
session.append('compact/start', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
await ctx.plugin(InvariantService)
await ctx.plugin(CompactInvariant)
expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow()
expect(() => session.append('compact/end', {
compactionId: TEST_COMPACTION_ID,
turn: 1,
error: 'resume failed',
})).not.toThrow()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
@@ -162,7 +175,8 @@ describe('compaction invariants', () => {
type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 },
})
ctx.emit('session/event', session, {
type: 'compact/start', seq: 2, time: 2, data: { turn: 1 },
type: 'compact/start', seq: 2, time: 2,
data: { compactionId: TEST_COMPACTION_ID, turn: 1 },
})
}).not.toThrow()
})
@@ -170,28 +184,30 @@ describe('compaction invariants', () => {
it('rejects compaction outside or for a different open turn', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/)
expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }))
.toThrow(/outside any open turn/)
startTurn(session)
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, 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 }))
expect(() => open.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }))
.toThrow(/standalone but turn 1 is open/)
const idle = ctx.sessions.create()
expect(() => idle.append('compact/start', { turn: 1 }))
expect(() => idle.append('compact/start', { compactionId: TEST_COMPACTION_ID, 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 }))
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
expect(() => session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null }))
.toThrow(/standalone compaction is still compacting/)
})
@@ -201,7 +217,7 @@ describe('compaction invariants', () => {
const session = ctx.sessions.create()
startTurn(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('compact/start', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
await ctx.plugin(InvariantService)
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
})
@@ -209,10 +225,10 @@ describe('compaction invariants', () => {
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
const ctx = await setup()
const standalone = ctx.sessions.create()
standalone.append('compact/start', { turn: null })
standalone.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })
expect(() => { startTurn(standalone) })
.toThrow(/turn\/start cannot cross an open standalone compaction/)
standalone.append('compact/end', { turn: null, error: 'cancelled' })
standalone.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'cancelled' })
expect(() => {
startTurn(standalone)
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -220,53 +236,151 @@ describe('compaction invariants', () => {
const numbered = ctx.sessions.create()
startTurn(numbered)
numbered.append('compact/start', { turn: 1 })
numbered.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
expect(() => numbered.append(
'turn/end',
{ turn: 1, reason: { kind: 'completed' } },
)).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
numbered.append('compact/end', { turn: 1, error: 'cancelled' })
numbered.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'cancelled' })
expect(() => numbered.append(
'turn/end',
{ turn: 1, reason: { kind: 'completed' } },
)).not.toThrow()
})
it('rejects a replacement checkpoint for another compaction transaction', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
const original = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
startTurn(session)
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/summary', summary())
expect(() => session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: compactCheckpointSource(NEXT_COMPACTION_ID),
}), {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/compaction checkpoint id .* does not match compact\/start id/)
})
it('requires checkpoint provenance to name an open transaction', async () => {
const ctx = await setup()
const withoutStart = ctx.sessions.create()
const original = withoutStart.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(() => withoutStart.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: compactCheckpointSource(TEST_COMPACTION_ID),
}), {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/no matching compact\/start/)
const emptyCommand = ctx.sessions.create()
const replaced = emptyCommand.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
startTurn(emptyCommand)
emptyCommand.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
expect(() => emptyCommand.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: compactCheckpointSource(TEST_COMPACTION_ID, CommandId('')),
}), {
surfaceOp: { op: 'replace', start: replaced.seq, end: replaced.seq },
sourceEventSeqs: [replaced.seq],
})).toThrow(/checkpoint sourceCommandId must be a non-empty string/)
})
it.each([
['empty start id', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { compactionId: CompactionId(''), turn: 1 })
}, /compact\/start compactionId must be a non-empty string/],
['empty start source command id', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: CommandId(''),
turn: 1,
})
}, /compact\/start sourceCommandId must be a non-empty string/],
['summary without start', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/summary', summary())
}, /no matching compact\/start/],
['nested start', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/start', { turn: 2 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: 2 })
}, /still compacting/],
['repeated summary', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/summary', summary())
session.append('compact/summary', summary())
}, /repeated within one compaction/],
['summary for another compaction', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/summary', summary({ compactionId: NEXT_COMPACTION_ID }))
}, /compact\/summary id .* does not match compact\/start id/],
['summary for another source command', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: TEST_COMMAND_ID,
turn: 1,
})
session.append('compact/summary', summary({ sourceCommandId: NEXT_COMMAND_ID }))
}, /compact\/summary sourceCommandId .* does not match compact\/start sourceCommandId/],
['empty shadow set', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/summary', summary({ shadowedSeqs: [] }))
}, /shadowedSeqs must be non-empty/],
['wrong endpoints', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } }))
}, /shadowedRange must match/],
['invalid token count', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/summary', summary({ shadowedTokenCount: -1 }))
}, /non-negative safe integer/],
['end without start', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/end', { turn: 1, error: 'failed' })
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' })
}, /no matching compact\/start/],
['wrong end turn', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/end', { turn: 2, error: 'failed' })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'failed' })
}, /does not match/],
['end for another compaction', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: 1, error: 'failed' })
}, /compact\/end id .* does not match compact\/start id/],
['end missing the source command', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: TEST_COMMAND_ID,
turn: 1,
})
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' })
}, /compact\/end sourceCommandId .* does not match compact\/start sourceCommandId/],
['empty end source command id', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: TEST_COMMAND_ID,
turn: 1,
})
session.append('compact/end', {
compactionId: TEST_COMPACTION_ID,
sourceCommandId: CommandId(''),
turn: 1,
error: 'failed',
})
}, /compact\/end sourceCommandId must be a non-empty string/],
['success without summary', (session: ReturnType<Context['sessions']['create']>) => {
session.append('compact/start', { turn: 1 })
session.append('compact/end', { turn: 1 })
session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 })
}, /requires one compact\/summary/],
])('rejects %s', async (_name, action, message) => {
const ctx = await setup()

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/brand"
},
{
"path": "../../../vendor/cosmokit"
},
@@ -17,6 +20,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../core/session"
},

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'tsdown'
/** Builds each published entry as a self-contained file admitted by the package whitelist. */
export default defineConfig([
{
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
])