Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # 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/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -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: 3c3644adce23c12db37241bf797ea614d273a0fb
|
||||
README.zh.md: 260a92154ecd33cb127391af5ded399a2bc20038
|
||||
README.md: aa9fa6d9419de87a7df23a437f5ea8694d981b28
|
||||
README.zh.md: e771eb4bc76358242737d92f92ec36324f55bf2b
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 deferred model-facing consumer. All **product** packages.
|
||||
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.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
| `command-compact/` | Human `/compact` command over the backend-independent `compactNow()` seam | (registers on `ctx.commands`) |
|
||||
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, deterministic pruning at `compact/compact-tool-result-prune/`, and the command at `compact/command-compact/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, command, or automatic callers.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及暂缓实现的面向模型消费方。这些全是**产品**包(package)。
|
||||
一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包(package)。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | 抽象压缩 seam(接口 + `compact/*` 事件 + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` | 后端:`ctx.tokenMeter` 压力 + 按 token 预算保留内容 + `llm.stream()` 摘要生成 | (注册 `ctx.compact`) |
|
||||
| `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` |
|
||||
| `tool-compact/`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` | (注册到 `ctx.tools`) |
|
||||
| `command-compact/` | 面向用户的 `/compact` 命令,基于后端无关的 `compactNow()` seam | (注册到 `ctx.commands`) |
|
||||
|
||||
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器或调用方。
|
||||
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。
|
||||
|
||||
6
packages/compact/command-compact/README.i18n.yaml
Normal file
6
packages/compact/command-compact/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: 1445e76f8328a9ac1c5f9dd43094f1c1cd5d2ad4
|
||||
README.zh.md: 0fb306afb3713e47fb17d2c914a4f691b63b77eb
|
||||
66
packages/compact/command-compact/README.md
Normal file
66
packages/compact/command-compact/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# @deepseek-ai/dsh-command-compact
|
||||
|
||||
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 it; the shipped TUI 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
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/compact` | Summarize one useful balanced older span even below automatic pressure, then report the replaced history-item count and estimated tokens after the standalone bracket is flushed. |
|
||||
| `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. |
|
||||
| `/compact <anything>` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. |
|
||||
|
||||
The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history.
|
||||
|
||||
Expected `ManualCompactionError` codes become stable direct errors:
|
||||
|
||||
| Code | Direct result |
|
||||
|---|---|
|
||||
| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
|
||||
| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
|
||||
| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
|
||||
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
|
||||
| `persistence` | `Compaction finished, but the session could not be saved.` |
|
||||
|
||||
The busy result is intentionally process-scoped: a live unmatched marker blocks, while a marker older than the newest `session/end-seed` is stale and does not. Unexpected implementation failures reject dispatch. Cancellation remains authoritative; the backend completes its required close/flush cleanup, and the command settles internally as `Compaction cancelled.` while the command executor stops waiting with its cancellation error. Plugin disposal first unregisters `/compact`, then drains every handler that already started, so root teardown cannot pass an aborted command's close or flush boundary.
|
||||
|
||||
Prompts submitted while compaction runs remain accepted in the agent's ordinary FIFO with the same identity and wakeup facts. They start only after the compaction's explicit durability checkpoint and admission release. Idle injected context is not held: it may be logged between `compact/start` and `compact/end`, and positional replacement leaves it visible after the checkpoint.
|
||||
|
||||
## Composition
|
||||
|
||||
The producer injects `commands` and `compact`. Mount the command registry, one backend, and this plugin:
|
||||
|
||||
```yaml
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
```
|
||||
|
||||
The TUI example and CLI host mount it beside `compact-basic`. Automation surfaces that compose no command registry keep automatic compaction only.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Human `/compact` control
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The slash input and direct result never enter a model request. An accepted compaction separately replaces an older span with the backend's user-role checkpoint inside a standalone `compact/* { turn: null }` bracket.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The command lifecycle adds no model tokens. A successful compaction reduces later requests by replacing the selected span with one framed summary; summarization itself is one auxiliary request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Discovery and command bookkeeping do not affect the cache. The accepted surface replacement invalidates reuse from the first shadowed history token.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Idle-only** — `/compact` reports `busy` when a turn or already accepted waking prompt has right of way; the command itself is not queued.
|
||||
- **No range or policy arguments** — the argument-free form keeps behavior stable across command adapters. Explicit ranges remain the programmatic `compactRegion()` path.
|
||||
- **Command adapters only** — surfaces without `ctx.commands` cannot invoke it and rely on automatic pressure compaction.
|
||||
66
packages/compact/command-compact/README.zh.md
Normal file
66
packages/compact/command-compact/README.zh.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# @deepseek-ai/dsh-command-compact
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
|
||||
|
||||
## 命令契约
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/compact` | 即使未达到自动压力,也摘要一段有效、平衡的较早范围;独立标记对 flush 后,报告被替换的历史项数量与估算 token 数。 |
|
||||
| `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 |
|
||||
| `/compact <anything>` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 |
|
||||
|
||||
该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。
|
||||
|
||||
预期的 `ManualCompactionError` 代码会成为稳定的直接错误:
|
||||
|
||||
| 代码 | 直接结果 |
|
||||
|---|---|
|
||||
| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
|
||||
| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
|
||||
| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
|
||||
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
|
||||
| `persistence` | `Compaction finished, but the session could not be saved.` |
|
||||
|
||||
busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞,而早于最新 `session/end-seed` 的标记已陈旧,不会阻塞。意外实现故障会拒绝分发。取消仍具有最终决定权;后端会完成必需的闭合/flush 清理,命令内部以 `Compaction cancelled.` 结算,而命令执行器会因取消错误停止等待。插件处置会先注销 `/compact`,再等待所有已开始的处理器结算,因此根级 teardown 不会越过已中止命令的闭合或 flush 边界。
|
||||
|
||||
压缩运行期间提交的提示词仍会按 agent 的普通 FIFO 获得接纳,保留相同的身份与唤醒信息。它们仅在压缩的显式持久性检查点和接纳预留释放后启动。空闲注入的上下文不受阻塞:它可以记录在 `compact/start` 与 `compact/end` 之间,位置替换会使其在检查点之后保持可见。
|
||||
|
||||
## 组合
|
||||
|
||||
生产方注入 `commands` 和 `compact`。挂载命令注册表、一个后端与本插件:
|
||||
|
||||
```yaml
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
```
|
||||
|
||||
TUI 示例与 CLI host 将它挂载在 `compact-basic` 旁。未组合命令注册表的自动化接口只保留自动压缩。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户 `/compact` 控制
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
斜杠输入与直接结果绝不会进入模型请求。已获接纳的压缩会另外在独立的 `compact/* { turn: null }` 标记对内,用后端的 user 角色检查点替换一段较早范围。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
命令生命周期不会增加模型 token。成功压缩会用一份带框架的摘要替换所选范围,从而减少后续请求;摘要生成本身需要一次辅助请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
命令发现与簿记不会影响缓存。已获接纳的 surface 替换会从第一个被遮蔽的历史 token 起使复用失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅限空闲状态**:当一个轮次或已获接纳的唤醒提示词拥有优先权时,`/compact` 会报告 `busy`;命令本身不会排队。
|
||||
- **不接受范围或策略参数**:无参数形式使各命令适配器的行为保持稳定。显式范围仍由编程接口 `compactRegion()` 处理。
|
||||
- **仅限命令适配器**:没有 `ctx.commands` 的接口无法调用该命令,只能依赖自动压力压缩。
|
||||
46
packages/compact/command-compact/package.json
Normal file
46
packages/compact/command-compact/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-command-compact",
|
||||
"description": "Human-facing slash command for explicit session compaction",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
103
packages/compact/command-compact/src/index.ts
Normal file
103
packages/compact/command-compact/src/index.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Human-facing `/compact` command over the backend-independent compaction seam.
|
||||
* @module @deepseek-ai/dsh-command-compact
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { ManualCompactionError } from '@deepseek-ai/dsh-compact'
|
||||
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
export const name = 'command-compact'
|
||||
export const inject = ['commands', 'compact']
|
||||
|
||||
const USAGE = 'Usage: /compact (no arguments)'
|
||||
|
||||
/** Fail loudly if a locally closed union gains an unhandled member. */
|
||||
/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
|
||||
function assertNever(value: never): never {
|
||||
throw new TypeError(`unknown manual compaction error code: ${String(value)}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/** Convert expected capability failures into concise human-only outcomes. */
|
||||
function expectedFailure(error: ManualCompactionError): CommandResult {
|
||||
switch (error.code) {
|
||||
case 'busy':
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
|
||||
}
|
||||
case 'changed':
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
|
||||
}
|
||||
case 'summary':
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
|
||||
}
|
||||
case 'commit':
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
|
||||
}
|
||||
case 'persistence':
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'Compaction finished, but the session could not be saved.',
|
||||
}
|
||||
/* v8 ignore next 2 -- ManualCompactionErrorCode is closed and every member is handled above */
|
||||
default: return assertNever(error.code)
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute one argument-free manual compaction request. */
|
||||
async function executeCompact(
|
||||
ctx: Context,
|
||||
invocation: CommandInvocation,
|
||||
): Promise<CommandResult> {
|
||||
if (invocation.rawInput.trim().length > 0) {
|
||||
return { kind: 'error', text: USAGE }
|
||||
}
|
||||
try {
|
||||
const result = await ctx.compact.compactNow(invocation.agent, invocation.signal)
|
||||
if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
|
||||
return {
|
||||
kind: 'success',
|
||||
text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' }
|
||||
if (error instanceof ManualCompactionError) return expectedFailure(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register `/compact` for every composed human-command adapter.
|
||||
* @param ctx - context carrying the command registry and the compaction seam.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const active = new Set<Promise<CommandResult>>()
|
||||
const handler = (invocation: CommandInvocation): Promise<CommandResult> => {
|
||||
const operation = executeCompact(ctx, invocation)
|
||||
active.add(operation)
|
||||
const retire = (): void => { active.delete(operation) }
|
||||
// Both branches retire without rethrowing, so the derived observer promise
|
||||
// cannot become an unhandled mirror of an expected handler rejection.
|
||||
void operation.then(retire, retire)
|
||||
return operation
|
||||
}
|
||||
|
||||
ctx.effect(function* () {
|
||||
// Yield drain before registration: composite teardown is LIFO, so no new
|
||||
// invocation can enter while already-started handler promises quiesce.
|
||||
yield async () => { await Promise.allSettled(active) }
|
||||
yield ctx.commands.register({
|
||||
name: 'compact',
|
||||
description: 'Compact older conversation history',
|
||||
handler,
|
||||
})
|
||||
}, 'command-compact lifecycle')
|
||||
}
|
||||
30
packages/compact/command-compact/src/invariant.ts
Normal file
30
packages/compact/command-compact/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-command-compact`.
|
||||
* @module @deepseek-ai/dsh-command-compact/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'command-compact-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this command adapter owns no state or event stream; the compaction seam owns
|
||||
* the balanced durable transaction and the command registry owns registration and dispatch lifecycle.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
248
packages/compact/command-compact/tests/command-compact.spec.ts
Normal file
248
packages/compact/command-compact/tests/command-compact.spec.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import {
|
||||
CompactService,
|
||||
ManualCompactionError,
|
||||
type CompactAgentContext,
|
||||
type CompactionResult,
|
||||
type CompactionTrigger,
|
||||
type ManualCompactAgentContext,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
const RESULT: CompactionResult = {
|
||||
startSeq: 10,
|
||||
summarySeq: 11,
|
||||
endSeq: 13,
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start: 1, end: 7 },
|
||||
shadowedSeqs: [1, 3, 7],
|
||||
shadowedTokenCount: 42,
|
||||
}
|
||||
|
||||
class StubCompactService extends CompactService {
|
||||
result: CompactionResult | null = RESULT
|
||||
failure: unknown
|
||||
operation: (() => Promise<CompactionResult | null>) | undefined
|
||||
calls: { agent: ManualCompactAgentContext; signal: AbortSignal }[] = []
|
||||
|
||||
override compactIfNeeded(
|
||||
_agent: CompactAgentContext,
|
||||
_trigger: CompactionTrigger,
|
||||
_signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
override compactRegion(): Promise<CompactionResult> {
|
||||
return Promise.resolve(RESULT)
|
||||
}
|
||||
|
||||
override compactNow(
|
||||
agent: ManualCompactAgentContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
this.calls.push({ agent, signal })
|
||||
if (this.operation !== undefined) return this.operation()
|
||||
return this.failure === undefined
|
||||
? Promise.resolve(this.result)
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
|
||||
: Promise.reject(this.failure)
|
||||
}
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
readonly ctx: Context
|
||||
readonly compact: StubCompactService
|
||||
readonly agent: Agent
|
||||
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
async function harness(): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CommandService)
|
||||
const compact = new StubCompactService(ctx)
|
||||
const plugin = await ctx.plugin(commandCompact)
|
||||
const session = new Session(SessionId('command-compact'))
|
||||
const agent = {
|
||||
session,
|
||||
status: 'idle',
|
||||
options: {},
|
||||
reserveTurnAdmission: () => () => undefined,
|
||||
} as unknown as Agent
|
||||
return { ctx, compact, agent, plugin }
|
||||
}
|
||||
|
||||
async function run(
|
||||
test: Harness,
|
||||
suffix = '',
|
||||
controller = new AbortController(),
|
||||
): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
|
||||
const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, controller.signal)
|
||||
if (execution === undefined) throw new Error('compact command was not registered')
|
||||
return execution
|
||||
}
|
||||
|
||||
/** Assert the executor-owned lifecycle pair and absence from model history. */
|
||||
function expectLastLifecycle(
|
||||
test: Harness,
|
||||
args: string,
|
||||
outcome: { readonly kind: 'success' | 'error'; readonly text?: string },
|
||||
): string {
|
||||
const lifecycle = test.agent.session.events.slice(-2)
|
||||
const runEvent = lifecycle[0]
|
||||
const doneEvent = lifecycle[1]
|
||||
if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') {
|
||||
throw new Error(`expected command lifecycle pair, got ${lifecycle.map(event => event.type).join(',')}`)
|
||||
}
|
||||
expect(lifecycle.map(event => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{
|
||||
type: 'command/run',
|
||||
data: {
|
||||
commandId: runEvent.data.commandId,
|
||||
name: 'compact',
|
||||
args,
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'command/done',
|
||||
data: {
|
||||
commandId: runEvent.data.commandId,
|
||||
...outcome,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(doneEvent.data.commandId).toBe(runEvent.data.commandId)
|
||||
expect(test.agent.session.surface.nodes).toEqual([])
|
||||
expect(test.agent.session.deriveMessages()).toEqual([])
|
||||
return runEvent.data.commandId
|
||||
}
|
||||
|
||||
describe('@deepseek-ai/dsh-command-compact registration', () => {
|
||||
it('registers one argument-free command with Loader-safe exports and disposes it', async () => {
|
||||
const test = await harness()
|
||||
expect(commandCompact.name).toBe('command-compact')
|
||||
expect(commandCompact.inject).toEqual(['commands', 'compact'])
|
||||
expect('default' in commandCompact).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(commandCompact)).toBe(commandCompact)
|
||||
expect(test.ctx.commands.list(test.agent)).toContainEqual({
|
||||
name: 'compact',
|
||||
description: 'Compact older conversation history',
|
||||
})
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('/compact human command', () => {
|
||||
it('reports success with useful accounting and forwards the exact target and signal', async () => {
|
||||
const test = await harness()
|
||||
const controller = new AbortController()
|
||||
const execution = await run(test, '', controller)
|
||||
expect(execution.result).toEqual({
|
||||
kind: 'success',
|
||||
text: 'Compacted 3 history items (~42 tokens).',
|
||||
})
|
||||
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
|
||||
expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }])
|
||||
})
|
||||
|
||||
it('returns direct no-history and argument-rejection results', async () => {
|
||||
const test = await harness()
|
||||
test.compact.result = null
|
||||
const empty = await run(test)
|
||||
expect(empty.result).toEqual({
|
||||
kind: 'success',
|
||||
text: 'No compactable history yet.',
|
||||
})
|
||||
expect(empty.commandId).toBe(expectLastLifecycle(test, '', empty.result))
|
||||
|
||||
const rejected = await run(test, ' now')
|
||||
expect(rejected.result).toEqual({
|
||||
kind: 'error',
|
||||
text: 'Usage: /compact (no arguments)',
|
||||
})
|
||||
expect(rejected.commandId).toBe(expectLastLifecycle(test, ' now', rejected.result))
|
||||
expect(test.compact.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
|
||||
['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
|
||||
['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
|
||||
['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],
|
||||
['persistence', 'Compaction finished, but the session could not be saved.'],
|
||||
] as const)('maps expected %s failures to direct errors', async (code, text) => {
|
||||
const test = await harness()
|
||||
test.compact.failure = new ManualCompactionError(code, 'backend detail')
|
||||
const execution = await run(test)
|
||||
expect(execution.result).toEqual({ kind: 'error', text })
|
||||
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
|
||||
})
|
||||
|
||||
it('preserves cancellation and unexpected implementation failures', async () => {
|
||||
const cancelled = await harness()
|
||||
const controller = new AbortController()
|
||||
const abort = new Error('operator cancelled')
|
||||
cancelled.compact.operation = () => {
|
||||
controller.abort(abort)
|
||||
return Promise.reject(new ManualCompactionError('summary', 'late failure'))
|
||||
}
|
||||
await expect(run(cancelled, '', controller)).rejects.toBe(abort)
|
||||
expectLastLifecycle(cancelled, '', { kind: 'error', text: abort.message })
|
||||
|
||||
const unexpected = await harness()
|
||||
const bug = new Error('unexpected backend bug')
|
||||
unexpected.compact.failure = bug
|
||||
await expect(run(unexpected)).rejects.toBe(bug)
|
||||
expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message })
|
||||
})
|
||||
|
||||
it('drains an aborted handler through close and flush before plugin disposal settles', async () => {
|
||||
const test = await harness()
|
||||
const controller = new AbortController()
|
||||
const abort = new Error('operator cancelled')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const allowClose = Promise.withResolvers<undefined>()
|
||||
const closed = Promise.withResolvers<undefined>()
|
||||
const allowFlush = Promise.withResolvers<undefined>()
|
||||
const flushed = Promise.withResolvers<undefined>()
|
||||
test.compact.operation = async () => {
|
||||
started.resolve(undefined)
|
||||
await allowClose.promise
|
||||
closed.resolve(undefined)
|
||||
await allowFlush.promise
|
||||
flushed.resolve(undefined)
|
||||
throw abort
|
||||
}
|
||||
|
||||
const execution = run(test, '', controller)
|
||||
await started.promise
|
||||
controller.abort(abort)
|
||||
await expect(execution).rejects.toBe(abort)
|
||||
|
||||
let disposed = false
|
||||
const disposal = test.plugin.dispose()
|
||||
void disposal.then(() => { disposed = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
allowClose.resolve(undefined)
|
||||
await closed.promise
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
allowFlush.resolve(undefined)
|
||||
await flushed.promise
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
18
packages/compact/command-compact/tests/invariant.spec.ts
Normal file
18
packages/compact/command-compact/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as invariant from '@deepseek-ai/dsh-command-compact/invariant'
|
||||
|
||||
describe('command-compact invariant companion', () => {
|
||||
it('registers the package-owned no-op installer', async () => {
|
||||
const register = vi.fn().mockReturnValue(() => {})
|
||||
const ctx = { invariants: { register } } as never
|
||||
const dispose = await invariant.apply(ctx)
|
||||
expect(invariant.name).toBe('command-compact-invariant')
|
||||
expect(invariant.inject).toEqual(['invariants'])
|
||||
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-command-compact', expect.any(Function))
|
||||
expect(() => {
|
||||
const install = register.mock.calls[0]![1] as () => void
|
||||
install()
|
||||
}).not.toThrow()
|
||||
expect(dispose).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import {
|
||||
CompactService,
|
||||
type CompactAgentContext,
|
||||
type CompactionResult,
|
||||
type CompactionTrigger,
|
||||
type ManualCompactAgentContext,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const RESULT: CompactionResult = {
|
||||
startSeq: 1,
|
||||
summarySeq: 2,
|
||||
endSeq: 4,
|
||||
summary: [{ type: 'text', text: 'loader summary' }],
|
||||
shadowedRange: { start: 3, end: 8 },
|
||||
shadowedSeqs: [3, 5, 8],
|
||||
shadowedTokenCount: 99,
|
||||
}
|
||||
|
||||
class LoaderCompactService extends CompactService {
|
||||
override compactIfNeeded(
|
||||
_agent: CompactAgentContext,
|
||||
_trigger: CompactionTrigger,
|
||||
_signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
override compactRegion(): Promise<CompactionResult> {
|
||||
return Promise.resolve(RESULT)
|
||||
}
|
||||
|
||||
override compactNow(
|
||||
_agent: ManualCompactAgentContext,
|
||||
_signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
return Promise.resolve(RESULT)
|
||||
}
|
||||
}
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe('command-compact real Loader composition', () => {
|
||||
it('discovers and executes /compact through the assembled command plane', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-command-compact-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-commands'",
|
||||
"- name: '@test/compact-backend'",
|
||||
"- name: '@deepseek-ai/dsh-command-compact'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-commands', CommandService],
|
||||
['@test/compact-backend', LoaderCompactService],
|
||||
['@deepseek-ai/dsh-command-compact', commandCompact],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
|
||||
const session = new Session(SessionId('loader-command-compact'))
|
||||
const agent = {
|
||||
session,
|
||||
status: 'idle',
|
||||
options: {},
|
||||
reserveTurnAdmission: () => () => undefined,
|
||||
} as unknown as Agent
|
||||
expect(context.commands.list(agent)).toContainEqual({
|
||||
name: 'compact',
|
||||
description: 'Compact older conversation history',
|
||||
})
|
||||
const execution = await context.commands.execute(agent, '/compact', new AbortController().signal)
|
||||
if (execution === undefined) throw new Error('Loader composition did not resolve /compact')
|
||||
expect(execution.result).toEqual({
|
||||
kind: 'success',
|
||||
text: 'Compacted 3 history items (~99 tokens).',
|
||||
})
|
||||
expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{
|
||||
type: 'command/run',
|
||||
data: {
|
||||
commandId: execution.commandId,
|
||||
name: 'compact',
|
||||
args: '',
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'command/done',
|
||||
data: {
|
||||
commandId: execution.commandId,
|
||||
kind: 'success',
|
||||
text: 'Compacted 3 history items (~99 tokens).',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(session.surface.nodes).toEqual([])
|
||||
expect(session.deriveMessages()).toEqual([])
|
||||
})
|
||||
})
|
||||
27
packages/compact/command-compact/tsconfig.json
Normal file
27
packages/compact/command-compact/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../compact"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: eed9412e335a0b703f3d1d5cb7112de10a19e9a0
|
||||
README.zh.md: 763736760e4e7a6acd34a0a085e0e3b685f0a8c9
|
||||
README.md: 0e49f94ed65e0cec1f06587a647acfa134a7f70d
|
||||
README.zh.md: 52261c04a552b9fdfad438a0c7bf87e3dd9416bf
|
||||
|
||||
@@ -17,11 +17,11 @@ This backend owns the compaction policy:
|
||||
- **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 and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. 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.
|
||||
- **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** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/pre-step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress.
|
||||
- **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`.
|
||||
- **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** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. 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 any progress.
|
||||
- **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 summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
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?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
@@ -46,21 +46,25 @@ An adapter may return no capacity for a valid dynamic route, and resolved capaci
|
||||
|
||||
## Usage
|
||||
|
||||
`BasicCompactService` requires `ctx.llm`, `ctx.tokenMeter`, and `ctx.sessions`. The composition below receives `ctx.llm` from its host and installs the other two services:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
export const name = 'compact-basic'
|
||||
export const inject = ['llm', 'tokenMeter']
|
||||
export const inject = ['llm']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(TokenMeterService)
|
||||
ctx.plugin(BasicCompactService)
|
||||
}
|
||||
```
|
||||
|
||||
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
|
||||
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure. The sibling [`dsh-command-compact`](../command-compact/README.md) calls `ctx.compact.compactNow(...)`; programmatic callers may also use any seam operation directly.
|
||||
|
||||
For example, the same compact plugin can safely serve models with different capacities and one target-specific policy:
|
||||
|
||||
@@ -136,7 +140,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
|
||||
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用。
|
||||
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
|
||||
- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/pre-step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。
|
||||
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
|
||||
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
|
||||
- **失败处理**:未配对的 `compact/start` 是不起作用的崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。即使已经取得进展,取消仍具有最终决定权。
|
||||
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
|
||||
|
||||
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。
|
||||
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`);事务会在 `compact/summary` 上保留这些字段。
|
||||
|
||||
## 配置(`BasicCompactConfig`)
|
||||
|
||||
@@ -46,21 +46,25 @@
|
||||
|
||||
## 用法
|
||||
|
||||
`BasicCompactService` 需要 `ctx.llm`、`ctx.tokenMeter` 和 `ctx.sessions`。以下组合从其宿主接收 `ctx.llm`,并安装另外两项服务:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
export const name = 'compact-basic'
|
||||
export const inject = ['llm', 'tokenMeter']
|
||||
export const inject = ['llm']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(TokenMeterService)
|
||||
ctx.plugin(BasicCompactService)
|
||||
}
|
||||
```
|
||||
|
||||
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)` 或 `ctx.compact.compactRegion(...)`。
|
||||
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩。同级 [`dsh-command-compact`](../command-compact/README.md) 调用 `ctx.compact.compactNow(...)`;编程调用方也可以直接使用任一 seam 操作。
|
||||
|
||||
例如,同一个压缩插件可以安全服务于容量不同的模型,并应用一项目标特定策略:
|
||||
|
||||
@@ -136,7 +140,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
@@ -20,9 +21,13 @@ import {
|
||||
resolveTargetPolicy,
|
||||
TargetPressureConfigError,
|
||||
} from './config.ts'
|
||||
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
||||
import {
|
||||
assertNoActiveCompaction,
|
||||
compactSurfaceRegion,
|
||||
selectCompactableRange,
|
||||
} from './region.ts'
|
||||
import { summarizeWithLlm } from './summarizer.ts'
|
||||
import type { SummarizationInput } from './summarizer.ts'
|
||||
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
@@ -39,6 +44,9 @@ export type {
|
||||
ResolvedTargetPolicy,
|
||||
} from './types.ts'
|
||||
|
||||
/** The region transaction's view of this service's dynamically dispatched summarizer. */
|
||||
type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise<SummaryResult>
|
||||
|
||||
/** Resolve the exact provider/model durably routed for the latest request. */
|
||||
function routedTarget(
|
||||
session: Session,
|
||||
@@ -92,7 +100,7 @@ const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
|
||||
* token meter.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm', 'tokenMeter']
|
||||
static inject = ['llm', 'tokenMeter', 'sessions']
|
||||
|
||||
static Config: z<BasicCompactConfig> = z.object({
|
||||
thresholdRatio: thresholdRatioSchema,
|
||||
@@ -233,7 +241,7 @@ export class BasicCompactService extends CompactService {
|
||||
input: SummarizationInput,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
): Promise<SummaryResult> {
|
||||
const target = conversationTarget(agent)
|
||||
const config = target === undefined
|
||||
? this.config
|
||||
@@ -287,6 +295,7 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context
|
||||
assertNoActiveCompaction(agent.session, 'automatic pressure compaction')
|
||||
const targetKey = `${target.provider}/${target.model}`
|
||||
if (context === undefined) {
|
||||
throw new TargetPressureConfigError(
|
||||
@@ -341,11 +350,67 @@ export class BasicCompactService extends CompactService {
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
const session = agent.session
|
||||
return compactSurfaceRegion({
|
||||
return compactSurfaceRegion(
|
||||
this.regionDependencies(),
|
||||
agent.session,
|
||||
start,
|
||||
end,
|
||||
agent,
|
||||
{ owner: 'current-turn', stability: 'whole-surface' },
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force one useful idle-session compaction below the pressure threshold, and
|
||||
* resolve only after its standalone marker pair is durably checkpointed.
|
||||
* @param agent - idle agent whose next-turn admission this call reserves.
|
||||
* @param signal - command-owned cancellation forwarded to summarization.
|
||||
* @returns the committed result, or `null` when no safe useful range exists.
|
||||
*/
|
||||
override async compactNow(
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
signal.throwIfAborted()
|
||||
const releaseTurnAdmission = agent.reserveTurnAdmission()
|
||||
if (releaseTurnAdmission === undefined) {
|
||||
throw new ManualCompactionError(
|
||||
'busy',
|
||||
'manual compaction requires an idle agent with no waking queued work',
|
||||
)
|
||||
}
|
||||
try {
|
||||
const range = selectCompactableRange(
|
||||
agent.session,
|
||||
this.ctx.tokenMeter.measure(agent.session),
|
||||
0,
|
||||
)
|
||||
if (range === null) return null
|
||||
return await compactSurfaceRegion(
|
||||
this.regionDependencies(),
|
||||
agent.session,
|
||||
range.start,
|
||||
range.end,
|
||||
agent,
|
||||
{
|
||||
owner: null,
|
||||
stability: 'selected-span',
|
||||
flush: () => this.ctx.sessions.flush(agent.session),
|
||||
},
|
||||
signal,
|
||||
)
|
||||
} finally {
|
||||
releaseTurnAdmission()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind the effective token meter and dynamically dispatched summarizer hook. */
|
||||
private regionDependencies(): { meter: TokenMeterService; summarize: RegionSummarize } {
|
||||
return {
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (input, owner, abort) => this.summarize(input, owner, abort),
|
||||
}, session, start, end, agent, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Surface retention selection and the log-recorded compaction transaction.
|
||||
* Surface retention selection and the shared log-recorded compaction
|
||||
* transaction for automatic open-turn and manual idle-session compaction.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/region
|
||||
*/
|
||||
@@ -7,12 +8,13 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
COMPACT_CHECKPOINT_SOURCE,
|
||||
ManualCompactionError,
|
||||
toolPairingBalancedAfter,
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
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'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -24,6 +26,62 @@ interface RegionDependencies {
|
||||
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
||||
}
|
||||
|
||||
/** One validated inclusive span of current surface positions. */
|
||||
interface SurfaceSelection {
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
readonly startIdx: number
|
||||
readonly endIdx: number
|
||||
readonly shadowedSeqs: readonly number[]
|
||||
}
|
||||
|
||||
/** A selection with its priced snapshot and the replay input built from it. */
|
||||
interface PreparedCompaction extends SurfaceSelection {
|
||||
readonly measurement: TokenMeasurement
|
||||
readonly selectedNodes: TokenMeasurement['nodes']
|
||||
readonly shadowedTokenCount: number
|
||||
readonly input: SummarizationInput
|
||||
}
|
||||
|
||||
interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
|
||||
readonly checkpointMessage: UserMessage
|
||||
}
|
||||
|
||||
interface CompactionTransactionOptions {
|
||||
/** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */
|
||||
readonly owner: 'current-turn' | null
|
||||
/** Surface relationship that must survive asynchronous summarization. */
|
||||
readonly stability: 'whole-surface' | 'selected-span'
|
||||
/** Optional durability checkpoint after a successfully closed bracket. */
|
||||
readonly flush?: () => Promise<void>
|
||||
}
|
||||
|
||||
interface CompactionEntryState {
|
||||
readonly openTurn: number | null
|
||||
readonly unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined
|
||||
readonly latestEndSeedSeq: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a summary whose replacement boundaries are no longer the ones it was
|
||||
* built from, distinguished from summarizer and shrink failures so a manual
|
||||
* caller can report the two causes differently.
|
||||
*/
|
||||
class SurfaceChangedError extends Error {}
|
||||
|
||||
/** Whether the summary may still replace the span it was built from. */
|
||||
type StabilityCheck = (
|
||||
dependencies: RegionDependencies,
|
||||
session: Session,
|
||||
prepared: PreparedCompaction,
|
||||
) => void
|
||||
|
||||
/** Failure captured after `compact/start` has committed. */
|
||||
interface TransactionFailure {
|
||||
readonly error: unknown
|
||||
readonly stage: 'summary' | 'commit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the next head-anchored range while retaining a priced recent tail
|
||||
* and never splitting an assistant tool-call/result pair.
|
||||
@@ -71,12 +129,18 @@ export function selectCompactableRange(
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and compact one positional surface span.
|
||||
* Run the single compaction transaction over one selected positional span.
|
||||
* Selection and validation are read-only. Idle/log validation and
|
||||
* `compact/start` are synchronously adjacent, so the durable opening marker is
|
||||
* the compaction lock before summarization yields. Every later failure makes
|
||||
* exactly one `compact/end` attempt; a failed close deliberately leaves the
|
||||
* unmatched start detectable.
|
||||
* @param dependencies - conversation meter and dynamically dispatched summarizer hook.
|
||||
* @param session - session whose surface is mutated.
|
||||
* @param start - inclusive first surface-node seq.
|
||||
* @param end - inclusive last surface-node seq.
|
||||
* @param agent - agent used by the summarizer.
|
||||
* @param options - bracket owner, stability rule, and optional durability checkpoint.
|
||||
* @param signal - optional summarization cancellation signal.
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
@@ -86,8 +150,151 @@ export async function compactSurfaceRegion(
|
||||
start: number,
|
||||
end: number,
|
||||
agent: Agent,
|
||||
options: CompactionTransactionOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
if (options.owner === null) signal?.throwIfAborted()
|
||||
const selection = validateSurfaceRegion(session, start, end)
|
||||
const entryState = inspectCompactionEntryState(session.events)
|
||||
assertCompactionInactive(
|
||||
entryState.unmatchedCompactionStart,
|
||||
entryState.latestEndSeedSeq,
|
||||
'compaction',
|
||||
)
|
||||
|
||||
let owner: number | null
|
||||
if (options.owner === null) {
|
||||
if (entryState.openTurn !== null) {
|
||||
throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn')
|
||||
}
|
||||
owner = null
|
||||
} else {
|
||||
if (entryState.openTurn === null) {
|
||||
throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn')
|
||||
}
|
||||
owner = entryState.openTurn
|
||||
}
|
||||
|
||||
const startEvent = session.append('compact/start', { turn: owner })
|
||||
const assertStable: StabilityCheck = options.stability === 'whole-surface'
|
||||
? assertWholeSurfaceUnchanged
|
||||
: assertSelectedSpanStable
|
||||
let failure: TransactionFailure | undefined
|
||||
let flushFailure: unknown
|
||||
let result: CompactionResult | undefined
|
||||
let closed = false
|
||||
let closing = false
|
||||
let stage: TransactionFailure['stage'] = 'summary'
|
||||
|
||||
try {
|
||||
const prepared = prepareCompaction(dependencies, session, selection)
|
||||
const summarized = await summarizeCompaction(dependencies, prepared, agent, 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 })
|
||||
closed = true
|
||||
result = completeCompaction(pending, endEvent)
|
||||
} catch (error: unknown) {
|
||||
failure = { error, stage: closing ? 'commit' : stage }
|
||||
if (!closing) {
|
||||
closing = true
|
||||
try {
|
||||
session.append('compact/end', { turn: owner, error: errorChain(error) })
|
||||
closed = true
|
||||
} catch (closeError: unknown) {
|
||||
failure = { error: closeError, stage: 'commit' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closed && options.flush !== undefined) {
|
||||
try {
|
||||
await options.flush()
|
||||
} catch (error: unknown) {
|
||||
flushFailure = error
|
||||
}
|
||||
}
|
||||
|
||||
if (options.owner === null) signal?.throwIfAborted()
|
||||
if (failure !== undefined) {
|
||||
if (options.owner === null) throwManualFailure(failure)
|
||||
throw failure.error
|
||||
}
|
||||
if (flushFailure !== undefined) {
|
||||
throw new ManualCompactionError(
|
||||
'persistence',
|
||||
'manual compaction durability checkpoint failed',
|
||||
{ cause: flushFailure },
|
||||
)
|
||||
}
|
||||
/* v8 ignore next -- every path without a result records and throws a failure above. */
|
||||
if (result === undefined) throw new Error('compaction committed without a result')
|
||||
return result
|
||||
}
|
||||
|
||||
/** Classify one closed manual attempt without weakening cancellation precedence. */
|
||||
function throwManualFailure(failure: TransactionFailure): never {
|
||||
if (failure.stage === 'commit') {
|
||||
throw new ManualCompactionError(
|
||||
'commit',
|
||||
'manual compaction did not commit cleanly',
|
||||
{ cause: failure.error },
|
||||
)
|
||||
}
|
||||
if (failure.error instanceof SurfaceChangedError) {
|
||||
throw new ManualCompactionError(
|
||||
'changed',
|
||||
'the compacted history changed during manual compaction',
|
||||
{ cause: failure.error },
|
||||
)
|
||||
}
|
||||
throw new ManualCompactionError(
|
||||
'summary',
|
||||
'manual compaction could not produce a smaller summary',
|
||||
{ cause: failure.error },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a durable unmatched compaction marker unless a later constructor-seed
|
||||
* boundary proves that its owner belongs to an earlier session lifecycle.
|
||||
* @param unmatchedCompactionStart - latest unmatched opening marker, if any.
|
||||
* @param latestEndSeedSeq - newest constructor-seed boundary, if any.
|
||||
* @param stage - operation label included in the busy diagnostic.
|
||||
*/
|
||||
function assertCompactionInactive(
|
||||
unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined,
|
||||
latestEndSeedSeq: number | undefined,
|
||||
stage: string,
|
||||
): void {
|
||||
if (unmatchedCompactionStart === undefined
|
||||
|| (latestEndSeedSeq !== undefined
|
||||
&& latestEndSeedSeq > unmatchedCompactionStart.seq)) return
|
||||
throw new ManualCompactionError(
|
||||
'busy',
|
||||
`${stage}: compaction already in progress; the session compaction lock is already active`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recheck the durable compaction lock after an asynchronous policy decision.
|
||||
* @param session - session whose latest marker state is inspected.
|
||||
* @param stage - operation label included in the busy diagnostic.
|
||||
*/
|
||||
export function assertNoActiveCompaction(session: Session, stage: string): void {
|
||||
const entryState = inspectCompactionEntryState(session.events)
|
||||
assertCompactionInactive(
|
||||
entryState.unmatchedCompactionStart,
|
||||
entryState.latestEndSeedSeq,
|
||||
stage,
|
||||
)
|
||||
}
|
||||
|
||||
/** Validate one requested surface-position span before asynchronous work begins. */
|
||||
function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection {
|
||||
const nodes = session.surface.nodes
|
||||
const startIdx = nodes.indexOf(start)
|
||||
const endIdx = nodes.indexOf(end)
|
||||
@@ -107,75 +314,145 @@ export async function compactSurfaceRegion(
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
const tail = inspectTurnTail(session.events)
|
||||
if (tail.compactionInProgress) throw new Error('compaction already in progress')
|
||||
if (tail.turn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
}
|
||||
return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
|
||||
}
|
||||
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
|
||||
const startEvent = session.append('compact/start', { turn: tail.turn })
|
||||
/** Snapshot pricing and replay input for a validated surface range. */
|
||||
function prepareCompaction(
|
||||
dependencies: RegionDependencies,
|
||||
session: Session,
|
||||
selection: SurfaceSelection,
|
||||
): PreparedCompaction {
|
||||
const measurement = dependencies.meter.measure(session)
|
||||
const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1)
|
||||
if (selectedNodes.length !== selection.shadowedSeqs.length
|
||||
|| selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) {
|
||||
throw new SurfaceChangedError('compaction: selected surface changed before summarization began')
|
||||
}
|
||||
return {
|
||||
...selection,
|
||||
measurement,
|
||||
selectedNodes,
|
||||
shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
|
||||
input: buildSummarizationInput(session, selection.shadowedSeqs),
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the summarizer and frame its replacement checkpoint. */
|
||||
async function summarizeCompaction(
|
||||
dependencies: RegionDependencies,
|
||||
prepared: PreparedCompaction,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummarizedCompaction> {
|
||||
const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
|
||||
const checkpointMessage = createUserMessage({
|
||||
content: frameSummary(summaryResult.summary),
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
})
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
|
||||
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
...prepared,
|
||||
...summaryResult,
|
||||
checkpointMessage,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a summary prepared against any earlier surface generation. */
|
||||
function assertWholeSurfaceUnchanged(
|
||||
dependencies: RegionDependencies,
|
||||
session: Session,
|
||||
prepared: PreparedCompaction,
|
||||
): void {
|
||||
const current = dependencies.meter.measure(session)
|
||||
if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) {
|
||||
throw new SurfaceChangedError('compaction: session surface changed during summarization')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require only that the selected span remain the same present, contiguous,
|
||||
* equally priced, balanced replacement target. Nodes added outside it remain
|
||||
* visible and do not invalidate the summary.
|
||||
*/
|
||||
function assertSelectedSpanStable(
|
||||
dependencies: RegionDependencies,
|
||||
session: Session,
|
||||
prepared: PreparedCompaction,
|
||||
): void {
|
||||
let current: SurfaceSelection
|
||||
try {
|
||||
// Capture after the lock event so a later surface mutation invalidates the
|
||||
// async selection before replacement. Unrelated log-only facts may append.
|
||||
const lockedMeasurement = dependencies.meter.measure(session)
|
||||
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
|
||||
if (selected.length !== shadowedSeqs.length
|
||||
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
|
||||
throw new Error('compaction: selected surface changed before summarization began')
|
||||
}
|
||||
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
|
||||
const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
|
||||
const {
|
||||
summary, rawOutput, provider, model, maxTokens, usage,
|
||||
} = await dependencies.summarize(summarizationInput, agent, signal)
|
||||
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
|
||||
throw new Error('compaction: session surface changed during summarization')
|
||||
}
|
||||
const framedSummary = frameSummary(summary)
|
||||
const checkpointMessage = createUserMessage({
|
||||
content: framedSummary,
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
})
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
|
||||
if (framedSummaryTokenCount >= shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
|
||||
)
|
||||
}
|
||||
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
...rawOutput === undefined ? {} : { rawOutput },
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
provider,
|
||||
model,
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
...usage === undefined ? {} : { usage },
|
||||
})
|
||||
session.append('user/message', checkpointMessage, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
const endEvent = session.append('compact/end', { turn: tail.turn })
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
current = validateSurfaceRegion(session, prepared.start, prepared.end)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
session.append('compact/end', { turn: tail.turn, error: message })
|
||||
throw error
|
||||
throw new SurfaceChangedError(
|
||||
'compaction: the selected span is no longer a valid replacement target',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) {
|
||||
throw new SurfaceChangedError('compaction: the selected span changed during summarization')
|
||||
}
|
||||
const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1)
|
||||
if (!isDeepStrictEqual(measured, prepared.selectedNodes)) {
|
||||
throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization')
|
||||
}
|
||||
}
|
||||
|
||||
/** Append one already-summarized provenance and replacement body without yielding. */
|
||||
function commitCompactionBody(
|
||||
session: Session,
|
||||
startEvent: SessionEvent<'compact/start'>,
|
||||
summarized: SummarizedCompaction,
|
||||
): Omit<CompactionResult, 'endSeq'> {
|
||||
const {
|
||||
start,
|
||||
end,
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
summary,
|
||||
rawOutput,
|
||||
provider,
|
||||
model,
|
||||
maxTokens,
|
||||
usage,
|
||||
checkpointMessage,
|
||||
} = summarized
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
...rawOutput === undefined ? {} : { rawOutput },
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [...shadowedSeqs],
|
||||
shadowedTokenCount,
|
||||
provider,
|
||||
model,
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
...usage === undefined ? {} : { usage },
|
||||
})
|
||||
session.append('user/message', checkpointMessage, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [...shadowedSeqs],
|
||||
shadowedTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach the successfully appended close event to a pending result. */
|
||||
function completeCompaction(
|
||||
pending: Omit<CompactionResult, 'endSeq'>,
|
||||
endEvent: SessionEvent<'compact/end'>,
|
||||
): CompactionResult {
|
||||
return { ...pending, endSeq: endEvent.seq }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,25 +483,38 @@ function buildSummarizationInput(
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the current turn boundary and latest compaction bracket once. */
|
||||
function inspectTurnTail(
|
||||
events: readonly SessionEvent[],
|
||||
): { turn: number | null; compactionInProgress: boolean } {
|
||||
let compactionInProgress = false
|
||||
let compactionStateKnown = false
|
||||
/** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
|
||||
function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState {
|
||||
let openTurn: number | null = null
|
||||
let openTurnStateKnown = false
|
||||
let unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined
|
||||
let compactionEntryStateKnown = false
|
||||
let latestEndSeedSeq: number | undefined
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (!compactionStateKnown) {
|
||||
if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
|
||||
latestEndSeedSeq = event.seq
|
||||
}
|
||||
if (!compactionEntryStateKnown) {
|
||||
if (event.type === 'compact/start') {
|
||||
compactionInProgress = true
|
||||
compactionStateKnown = true
|
||||
unmatchedCompactionStart = event
|
||||
compactionEntryStateKnown = true
|
||||
} else if (event.type === 'compact/end') {
|
||||
compactionStateKnown = true
|
||||
compactionEntryStateKnown = true
|
||||
}
|
||||
}
|
||||
if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
|
||||
if (event.type === 'turn/end') return { turn: null, compactionInProgress }
|
||||
if (!openTurnStateKnown) {
|
||||
if (event.type === 'turn/start') {
|
||||
openTurn = event.data.turn
|
||||
openTurnStateKnown = true
|
||||
} else if (event.type === 'turn/end') {
|
||||
openTurnStateKnown = true
|
||||
}
|
||||
}
|
||||
if (openTurnStateKnown
|
||||
&& compactionEntryStateKnown
|
||||
&& latestEndSeedSeq !== undefined) break
|
||||
}
|
||||
return { turn: null, compactionInProgress }
|
||||
return { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization request or that the context was compacted.',
|
||||
'- Output only the checkpoint text: do not call any tool or take any other action.',
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
@@ -1223,7 +1223,8 @@ describe('default one-shot summarizer', () => {
|
||||
expect(messages[0]).toEqual(prefix)
|
||||
const last = messages.at(-1)?.content[0]
|
||||
const lastText = last?.type === 'text' ? last.text : ''
|
||||
expect(lastText).toContain('Condense the conversation ABOVE')
|
||||
expect(lastText).toContain('Write concise English engineering prose.')
|
||||
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(lastText).toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
@@ -1785,6 +1786,7 @@ describe('automatic listener and loader composition', () => {
|
||||
it('loads and disposes the real zero-config service stack', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
const meterFiber = await ctx.plugin(TokenMeterService)
|
||||
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
|
||||
|
||||
|
||||
@@ -341,6 +341,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('') ?? ''
|
||||
expect(instruction).toContain('Write concise English engineering prose.')
|
||||
expect(instruction).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
|
||||
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
|
||||
expect(retry).toContain('RECOVERY CHECKPOINT')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-llm', LlmService],
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-token-meter', TokenMeterService],
|
||||
['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService],
|
||||
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
|
||||
@@ -55,6 +57,7 @@ describe('real Loader composition', () => {
|
||||
it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
|
||||
const loaded = await loadYaml([
|
||||
"- name: '@deepseek-ai/dsh-llm'",
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
"- name: '@deepseek-ai/dsh-token-meter'",
|
||||
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
|
||||
' config:',
|
||||
@@ -91,6 +94,7 @@ describe('real Loader composition', () => {
|
||||
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(SessionStore)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
models: { legacy: { thresholdRatio: 0.5 } },
|
||||
@@ -100,6 +104,7 @@ describe('real Loader composition', () => {
|
||||
it('rejects a capacity-independent merged ratio conflict during plugin load', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(SessionStore)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
retainRatio: 0.2,
|
||||
@@ -114,6 +119,7 @@ describe('real Loader composition', () => {
|
||||
it('rejects an incomplete model-policy summarization pair during plugin load', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(SessionStore)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
summarizationProvider: 'default-provider',
|
||||
|
||||
831
packages/compact/compact-basic/tests/manual-compact.spec.ts
Normal file
831
packages/compact/compact-basic/tests/manual-compact.spec.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
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 * 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 type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import {
|
||||
createAssistantMessage,
|
||||
createUserMessage,
|
||||
LlmAdapter,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LlmResolvedModelInfo,
|
||||
Message,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } 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'
|
||||
import type {
|
||||
SummarizationInput,
|
||||
SummaryResult,
|
||||
} from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
|
||||
|
||||
const MODEL = 'mock'
|
||||
const SIGNAL = new AbortController().signal
|
||||
const PROMPT = 'older conversation history '.repeat(60)
|
||||
|
||||
/** A summarizer under test control: it can block, fail, or mutate mid-call. */
|
||||
class GatedCompactService extends BasicCompactService {
|
||||
summary: ContentBlock[] = [{ type: 'text', text: 'checkpoint' }]
|
||||
rawOutput: ContentBlock[] | undefined
|
||||
usage: TokenUsage | undefined
|
||||
error: unknown
|
||||
gate: Promise<undefined> | undefined
|
||||
duringSummary: (() => void) | undefined
|
||||
calls: SummarizationInput[] = []
|
||||
|
||||
override async summarize(
|
||||
input: SummarizationInput,
|
||||
_agent: Agent,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
this.calls.push(input)
|
||||
this.duringSummary?.()
|
||||
if (this.gate !== undefined) await this.gate
|
||||
if (this.error !== undefined) throw this.error
|
||||
return {
|
||||
summary: this.summary,
|
||||
...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
|
||||
provider: 'summary-provider',
|
||||
model: 'summary-model',
|
||||
...this.usage === undefined ? {} : { usage: this.usage },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One text answer per request, with a context window large enough to avoid pressure. */
|
||||
class TextAdapter extends LlmAdapter {
|
||||
readonly requests: Message[][] = []
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 100_000 },
|
||||
})
|
||||
}
|
||||
|
||||
override async * stream(options: { messages: readonly Message[] }): AsyncIterable<StreamChunk> {
|
||||
this.requests.push([...options.messages])
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
interface LoopHarness {
|
||||
readonly ctx: Context
|
||||
readonly agent: Agent
|
||||
readonly compact: GatedCompactService
|
||||
readonly adapter: TextAdapter
|
||||
readonly log: string[]
|
||||
}
|
||||
|
||||
/** Real loop, session store, and invariant companions around manual compaction. */
|
||||
async function loopHarness(): Promise<LoopHarness> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
await ctx.plugin(CompactBasicInvariant)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
const adapter = new TextAdapter()
|
||||
ctx.llm.registerAdapter([MODEL], adapter)
|
||||
const compact = new GatedCompactService(ctx, { auto: false })
|
||||
const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
|
||||
const log: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
|
||||
if (event.type === 'turn/end') log.push('turn/end')
|
||||
if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
|
||||
if (event.type === 'compact/summary') log.push('compact/summary')
|
||||
if (event.type === 'compact/end') log.push(`compact/end:${String(event.data.turn)}`)
|
||||
if (event.type === 'user/message') log.push('user/message')
|
||||
})
|
||||
ctx.on('session/flush', () => { log.push('flush') })
|
||||
return { ctx, agent, compact, adapter, log }
|
||||
}
|
||||
|
||||
/** Drive one real turn so the closed history holds a compactable older span. */
|
||||
async function seedHistory(harness: LoopHarness): Promise<void> {
|
||||
harness.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await harness.agent.whenIdle()
|
||||
harness.log.length = 0
|
||||
}
|
||||
|
||||
/** Text of every derived model-visible message, in request order. */
|
||||
function derivedText(session: Session): string[] {
|
||||
return session.deriveMessages().map((message: Message) => message.content
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join(''))
|
||||
}
|
||||
|
||||
/** Await one classified manual-compaction rejection. */
|
||||
async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
|
||||
const caught: unknown = await operation.then(
|
||||
(value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
if (!(caught instanceof ManualCompactionError)) {
|
||||
throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
|
||||
}
|
||||
return caught
|
||||
}
|
||||
|
||||
/** The Error a classified failure wraps. */
|
||||
function causeOf(error: ManualCompactionError): Error {
|
||||
const { cause } = error
|
||||
if (!(cause instanceof Error)) throw new Error(`expected an Error cause, got ${String(cause)}`)
|
||||
return cause
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<undefined>; resolve: () => void } {
|
||||
const { promise, resolve } = Promise.withResolvers<undefined>()
|
||||
return { promise, resolve: () => { resolve(undefined) } }
|
||||
}
|
||||
|
||||
/** A closed-tail session with compactable exchanges and no live agent. */
|
||||
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
|
||||
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
|
||||
for (let index = 1; index <= turns; index += 1) {
|
||||
const turn = index === turns ? lastTurnNumber : index
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
if (index === 1) {
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
}
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: `answer ${turn}` }],
|
||||
source: { provider: MODEL, model: MODEL },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/** A fake idle agent whose admission reservation is scripted per test. */
|
||||
function fakeAgent(
|
||||
session: Session,
|
||||
reserve: () => (() => void) | undefined,
|
||||
): Agent {
|
||||
return {
|
||||
session,
|
||||
options: { provider: MODEL, model: MODEL },
|
||||
reserveTurnAdmission: reserve,
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
/** Service over a store-detached session for failure classification. */
|
||||
function detachedService(): { ctx: Context; compact: GatedCompactService; flushes: () => number } {
|
||||
const ctx = new Context()
|
||||
void new LlmService(ctx)
|
||||
void new SessionStore(ctx)
|
||||
void new TokenMeterService(ctx)
|
||||
ctx.llm.registerAdapter([MODEL], new TextAdapter())
|
||||
let flushes = 0
|
||||
vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
|
||||
flushes += 1
|
||||
return Promise.resolve()
|
||||
})
|
||||
return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes }
|
||||
}
|
||||
|
||||
function compactEvents(session: Session): Array<Session['events'][number]> {
|
||||
return session.events.filter(event => event.type.startsWith('compact/'))
|
||||
}
|
||||
|
||||
describe('compactNow through the real loop', () => {
|
||||
it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => {
|
||||
const harness = await loopHarness()
|
||||
const { agent, compact, adapter, log } = harness
|
||||
await seedHistory(harness)
|
||||
const gate = deferred()
|
||||
compact.gate = gate.promise
|
||||
|
||||
const running = compact.compactNow(agent, SIGNAL)
|
||||
await Promise.resolve()
|
||||
expect(log).toEqual(['compact/start:null'])
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'after compaction' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(log).toEqual(['compact/start:null'])
|
||||
|
||||
gate.resolve()
|
||||
const result = await running
|
||||
expect(result).not.toBeNull()
|
||||
await agent.whenIdle()
|
||||
|
||||
const start = log.indexOf('compact/start:null')
|
||||
const summary = log.indexOf('compact/summary')
|
||||
const end = log.indexOf('compact/end:null')
|
||||
const flush = log.indexOf('flush')
|
||||
const nextTurn = log.indexOf('turn/start:message')
|
||||
expect(start).toBeLessThan(summary)
|
||||
expect(summary).toBeLessThan(end)
|
||||
expect(end).toBeLessThan(flush)
|
||||
expect(flush).toBeLessThan(nextTurn)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const second = (adapter.requests[1] ?? []).map(message => message.content
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join(''))
|
||||
expect(second[0]).toContain('checkpoint')
|
||||
expect(second.at(-1)).toBe('after compaction')
|
||||
expect(second.some(text => text.includes(PROMPT))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
|
||||
const harness = await loopHarness()
|
||||
const { agent, compact } = harness
|
||||
await seedHistory(harness)
|
||||
compact.duringSummary = () => {
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'INJECTED CONTEXT' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
}
|
||||
|
||||
const result = await compact.compactNow(agent, SIGNAL)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const start = agent.session.events.findLast(event => event.type === 'compact/start')
|
||||
const injected = agent.session.events.findLast(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
|
||||
const end = agent.session.events.findLast(event => event.type === 'compact/end')
|
||||
expect(start).toBeDefined()
|
||||
expect(injected).toBeDefined()
|
||||
expect(end).toBeDefined()
|
||||
expect(start!.seq).toBeLessThan(injected!.seq)
|
||||
expect(injected!.seq).toBeLessThan(end!.seq)
|
||||
expect(result?.shadowedSeqs).not.toContain(injected?.seq)
|
||||
const messages = derivedText(agent.session)
|
||||
expect(messages[0]).toContain('checkpoint')
|
||||
expect(messages.at(-1)).toContain('INJECTED CONTEXT')
|
||||
expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the marker order when listeners attempt a re-entrant injection', async () => {
|
||||
const harness = await loopHarness()
|
||||
const { ctx, agent, compact } = harness
|
||||
await seedHistory(harness)
|
||||
const attempts: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type !== 'compact/start' && event.type !== 'compact/summary') return
|
||||
attempts.push(event.type)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: `from ${event.type}` }],
|
||||
source: { kind: 'plugin', plugin: 'listener' },
|
||||
}))
|
||||
})
|
||||
|
||||
const result = await compact.compactNow(agent, SIGNAL)
|
||||
|
||||
expect(attempts).toEqual(['compact/start', 'compact/summary'])
|
||||
expect(result).not.toBeNull()
|
||||
expect(derivedText(agent.session)[0]).toContain('checkpoint')
|
||||
expect(agent.session.events.filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0)
|
||||
const types = compactEvents(agent.session).map(event => event.type)
|
||||
expect(types).toEqual(['compact/start', 'compact/summary', 'compact/end'])
|
||||
})
|
||||
|
||||
it('reports busy without summarizing when a prompt already owns the next turn', async () => {
|
||||
const harness = await loopHarness()
|
||||
const { agent, compact, adapter } = harness
|
||||
await seedHistory(harness)
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'first in line' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases turn admission after a summarizer failure and records the failed attempt', async () => {
|
||||
const harness = await loopHarness()
|
||||
const { agent, compact, adapter } = harness
|
||||
await seedHistory(harness)
|
||||
compact.error = new Error('summarizer unavailable')
|
||||
const before = [...agent.session.surface.nodes]
|
||||
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('summary')
|
||||
expect(agent.session.surface.nodes).toEqual(before)
|
||||
const markers = compactEvents(agent.session)
|
||||
expect(markers.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
|
||||
expect(markers[1]?.type === 'compact/end' && markers[1].data.error)
|
||||
.toContain('summarizer unavailable')
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'runs after the failure' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compactNow transaction and failure classification', () => {
|
||||
it('returns null without writing a bracket for history that cannot be compacted', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = new Session(SessionId('empty'))
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
|
||||
expect(await compact.compactNow(agent, SIGNAL)).toBeNull()
|
||||
expect(released).toBe(1)
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
expect(compactEvents(session)).toEqual([])
|
||||
})
|
||||
|
||||
it('commits a standalone bracket without consuming a turn number and checkpoints durability', async () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const session = closedConversation(2, 7)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const result = await compact.compactNow(agent, SIGNAL)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
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 })
|
||||
})
|
||||
|
||||
it('reports a live unmatched bracket as busy without summarizing', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
session.append('compact/start', { turn: null })
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
expect(error.code).toBe('busy')
|
||||
expect(error.message).toContain('compaction lock is already active')
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
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 })
|
||||
const reloaded = new Session(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')
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
|
||||
expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER)
|
||||
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
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('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
|
||||
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
|
||||
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses an open turn in the log', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
expect(error.code).toBe('busy')
|
||||
expect(error.message).toContain('already has an open turn')
|
||||
})
|
||||
|
||||
it('reports busy and skips summarization when admission is unavailable', async () => {
|
||||
const { compact } = detachedService()
|
||||
const agent = fakeAgent(closedConversation(2), () => undefined)
|
||||
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a selected span replaced during summarization and records an error close', async () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
compact.duringSummary = () => {
|
||||
const [head] = session.surface.nodes
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'competing replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'rival' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: head!, end: head! },
|
||||
sourceEventSeqs: [head!],
|
||||
})
|
||||
}
|
||||
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
|
||||
expect(released).toBe(1)
|
||||
expect(flushes()).toBe(1)
|
||||
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
|
||||
})
|
||||
|
||||
it('rejects a selected span whose middle node was replaced during summarization', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(3)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
compact.duringSummary = () => {
|
||||
const middle = session.surface.nodes[1]
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'rewritten middle node' }],
|
||||
source: { kind: 'plugin', plugin: 'rival' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: middle!, end: middle! },
|
||||
sourceEventSeqs: [middle!],
|
||||
})
|
||||
}
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
expect(error.code).toBe('changed')
|
||||
expect(causeOf(error).message).toContain('span changed during summarization')
|
||||
})
|
||||
|
||||
it('revalidates the selected span after the summarizer continuation settles', async () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
const gate = deferred()
|
||||
compact.gate = gate.promise
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
const head = session.surface.nodes[0]!
|
||||
const generation = session.surface.replaceGeneration
|
||||
|
||||
const running = compact.compactNow(agent, SIGNAL)
|
||||
await Promise.resolve()
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
|
||||
gate.resolve()
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'late competing replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'rival' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: head, end: head },
|
||||
sourceEventSeqs: [head],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const error = await rejection(running)
|
||||
expect(error.code).toBe('changed')
|
||||
expect(causeOf(error).message).toContain('selected span')
|
||||
expect(released).toBe(1)
|
||||
expect(flushes()).toBe(1)
|
||||
expect(session.surface.replaceGeneration).toBe(generation + 1)
|
||||
expect(session.surface.nodes).not.toContain(head)
|
||||
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
|
||||
expect(session.events.some(event => event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a failing compact/end as commit failure and leaves one orphan', async () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
const append = session.append.bind(session)
|
||||
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === 'compact/end') throw new Error('boundary rejected')
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
expect(error.code).toBe('commit')
|
||||
expect(causeOf(error).message).toBe('boundary rejected')
|
||||
vi.restoreAllMocks()
|
||||
expect(flushes()).toBe(0)
|
||||
expect(session.events.findLast(event => event.type.startsWith('compact/'))?.type)
|
||||
.toBe('compact/summary')
|
||||
expect(compactEvents(session).filter(event => event.type === 'compact/start')).toHaveLength(1)
|
||||
|
||||
const calls = compact.calls.length
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect(compact.calls).toHaveLength(calls)
|
||||
})
|
||||
|
||||
it('keeps a failed error-close as the commit failure and does not flush', async () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
compact.error = new Error('summary rejected')
|
||||
const append = session.append.bind(session)
|
||||
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === 'compact/end') throw new Error('error boundary rejected')
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
vi.restoreAllMocks()
|
||||
expect(error.code).toBe('commit')
|
||||
expect(causeOf(error).message).toBe('error boundary rejected')
|
||||
expect(released).toBe(1)
|
||||
expect(flushes()).toBe(0)
|
||||
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start'])
|
||||
})
|
||||
|
||||
it('rejects a selected span whose pricing changed during summarization', async () => {
|
||||
const { ctx, compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
const meter = ctx.tokenMeter
|
||||
const original = meter.measure.bind(meter)
|
||||
compact.duringSummary = () => {
|
||||
vi.spyOn(meter, 'measure').mockImplementationOnce((target) => {
|
||||
const measurement = original(target)
|
||||
return {
|
||||
...measurement,
|
||||
nodes: measurement.nodes.map((node, index) =>
|
||||
index === 0 ? { ...node, tokens: node.tokens + 1 } : node),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('classifies a commit-body failure and still releases admission', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
let released = 0
|
||||
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')
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
vi.restoreAllMocks()
|
||||
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.turn).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a commit failure when the durability checkpoint also fails', async () => {
|
||||
const { ctx, compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
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')
|
||||
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')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('compacts a session with no durable turn boundary without creating one', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = new Session(SessionId('turnless'))
|
||||
for (const text of [PROMPT, 'recent tail']) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const result = await compact.compactNow(agent, SIGNAL)
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
it('classifies a durability failure after the standalone bracket committed', async () => {
|
||||
const { ctx, compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
|
||||
const cases = [
|
||||
{ name: 'busy', session: closedConversation(2), release: undefined },
|
||||
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
|
||||
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
|
||||
] as const
|
||||
|
||||
for (const testCase of cases) {
|
||||
const { ctx, compact } = detachedService()
|
||||
const reserve = vi.fn(() => testCase.release)
|
||||
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
|
||||
const agent = fakeAgent(testCase.session, reserve)
|
||||
const before = [...testCase.session.events]
|
||||
const reason = Object.freeze({ kind: 'cancelled', case: testCase.name })
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
|
||||
expect(reserve).not.toHaveBeenCalled()
|
||||
expect(measure).not.toHaveBeenCalled()
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
expect(testCase.session.events).toEqual(before)
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the exact cancellation reason when the summarizer also rejects', async () => {
|
||||
const { compact, flushes } = detachedService()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled by the caller')
|
||||
let released = 0
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
compact.duringSummary = () => { controller.abort(reason) }
|
||||
compact.error = new Error('summarizer aborted')
|
||||
|
||||
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
|
||||
expect(released).toBe(1)
|
||||
expect(flushes()).toBe(1)
|
||||
const events = compactEvents(session)
|
||||
expect(events.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
|
||||
expect(events[1]?.type === 'compact/end' && events[1].data.error)
|
||||
.toContain('summarizer aborted')
|
||||
})
|
||||
|
||||
it('aborts before committing when cancellation lands after summarization', async () => {
|
||||
const { compact } = detachedService()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled by the caller')
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
compact.duringSummary = () => { controller.abort(reason) }
|
||||
|
||||
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
|
||||
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for the durability checkpoint before cancellation wins and admission releases', async () => {
|
||||
const { ctx, compact } = detachedService()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled during flush')
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise)
|
||||
const session = closedConversation(2)
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
|
||||
const running = compact.compactNow(agent, controller.signal)
|
||||
let settled = false
|
||||
void running.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(flush).toHaveBeenCalledWith(session)
|
||||
})
|
||||
controller.abort(reason)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
expect(released).toBe(0)
|
||||
|
||||
flushGate.resolve(undefined)
|
||||
await expect(running).rejects.toBe(reason)
|
||||
expect(released).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves raw output and usage in the manual summary event', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
compact.rawOutput = [
|
||||
{ type: 'text', text: 'checkpoint' },
|
||||
{ type: 'reasoning', text: 'hidden reasoning' },
|
||||
]
|
||||
compact.usage = { inputTokens: 40, outputTokens: 5 }
|
||||
|
||||
await compact.compactNow(agent, SIGNAL)
|
||||
|
||||
const summary = session.events.find(event => event.type === 'compact/summary')
|
||||
expect(summary?.type === 'compact/summary' && summary.data.rawOutput).toEqual(compact.rawOutput)
|
||||
expect(summary?.type === 'compact/summary' && summary.data.usage).toEqual(compact.usage)
|
||||
})
|
||||
|
||||
it('makes duration derivable from the opening and closing marker times', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
compact.gate = new Promise<undefined>((resolve) => {
|
||||
setTimeout(() => { resolve(undefined) }, 5)
|
||||
})
|
||||
|
||||
await compact.compactNow(agent, SIGNAL)
|
||||
|
||||
const start = session.events.findLast(event => event.type === 'compact/start')
|
||||
const end = session.events.findLast(event => event.type === 'compact/end')
|
||||
expect(start).toBeDefined()
|
||||
expect(end).toBeDefined()
|
||||
expect(end!.time - start!.time).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('excludes concurrent automatic and manual compaction of one session', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(3)
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
const gate = deferred()
|
||||
compact.gate = gate.promise
|
||||
|
||||
const manual = compact.compactNow(agent, SIGNAL)
|
||||
await Promise.resolve()
|
||||
const nodes = session.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
nodes[0]!,
|
||||
nodes[1]!,
|
||||
agent,
|
||||
)).rejects.toThrow('compaction lock is already active')
|
||||
|
||||
gate.resolve()
|
||||
compact.gate = undefined
|
||||
const result: CompactionResult | null = await manual
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('excludes a manual request while an explicit region compaction runs', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(3)
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
const gate = deferred()
|
||||
compact.gate = gate.promise
|
||||
const nodes = session.surface.nodes
|
||||
const region = compact.compactRegion(nodes[0]!, nodes[1]!, agent)
|
||||
await Promise.resolve()
|
||||
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
|
||||
gate.resolve()
|
||||
compact.gate = undefined
|
||||
await expect(region).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 2) })
|
||||
})
|
||||
})
|
||||
@@ -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: 17a5420ae9fa23ce4021b4d4927ae5b95962f979
|
||||
README.zh.md: 56624efad3c4ffacb61c1a2e199e0461afc57242
|
||||
README.md: cfb65f2a786dd58d38a7020a8caefeb3d7372f52
|
||||
README.zh.md: e069bea9ef40d2e1ba7beead5b76324cfd56b839
|
||||
|
||||
@@ -10,22 +10,25 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
| `@deepseek-ai/dsh-command-compact` | the human `/compact` command over `ctx.compact.compactNow()` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
|
||||
## Service API (`ctx.compact`)
|
||||
|
||||
Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
|
||||
All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
||||
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
|
||||
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
|
||||
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
|
||||
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
|
||||
|
||||
`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed.
|
||||
|
||||
## Tool-pairing boundaries
|
||||
|
||||
@@ -45,11 +48,15 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
|
||||
|
||||
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
|
||||
|
||||
The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.
|
||||
|
||||
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
|
||||
|
||||
## Blocking
|
||||
|
||||
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
|
||||
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compact/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace. A live bracket cannot cross a `turn/start` or `turn/end`; during adoption, repair boundaries in the inherited prefix remain replayable when the later end-seed proves their open bracket stale.
|
||||
|
||||
The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compact/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compact/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released.
|
||||
|
||||
## Events
|
||||
|
||||
@@ -57,7 +64,11 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
|
||||
|
||||
## Implementing a backend
|
||||
|
||||
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
|
||||
Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
|
||||
|
||||
## Recognizing a checkpoint outside the host program (`./checkpoint`)
|
||||
|
||||
`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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -77,6 +88,6 @@ A successful backend replacement invalidates reuse from the first shadowed histo
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
|
||||
- **Human command, not a model tool** — `@deepseek-ai/dsh-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered.
|
||||
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
|
||||
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.
|
||||
|
||||
@@ -10,22 +10,25 @@
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper |
|
||||
| `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
|
||||
| `@deepseek-ai/dsh-tool-compact`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` 实现 |
|
||||
| `@deepseek-ai/dsh-command-compact` | 面向用户的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
|
||||
|
||||
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
|
||||
|
||||
## 服务 API(`ctx.compact`)
|
||||
|
||||
两个方法都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
|
||||
三个操作都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
|
||||
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 |
|
||||
| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 |
|
||||
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
|
||||
|
||||
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。
|
||||
|
||||
`compactIfNeeded` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放) 会停止进行中的摘要,不会留下越过取消时点继续运行的遗留模型调用。可以从所拥有会话的日志(当前尚未结束的轮次)恢复 `compact/*` 事件所属轮次,因此后端从日志中标记该值,而不信任调用方提供的值。
|
||||
`compactIfNeeded` 和 `compactNow` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放)会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`。
|
||||
|
||||
`ManualCompactionError.code` 是封闭集合 `busy | changed | summary | commit | persistence`。`changed` 和 `summary` 表示所选会话表层未被替换,但日志仍会记录失败尝试。`commit` 有意不判断是否发生了部分变更;`persistence` 表示内存中的 bracket 已闭合,但显式 flush 失败。
|
||||
|
||||
## 工具配对边界
|
||||
|
||||
@@ -45,11 +48,15 @@
|
||||
|
||||
表层变更(第 4 步)位于锁的起止范围**内**:`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start` 与 `compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`。
|
||||
|
||||
这对标记表示获取和释放锁的时间点,并非排他的事件容器。手动摘要等待期间,空闲的 `inject()` 可以在 start 与 end 之间追加不相关的上下文。因此,手动稳定性检查会重新验证所选 span,而不要求整个表层相等;位置替换会让该注入上下文在检查点之后保持可见。自动压缩则要求其活动轮次内的整个表层保持相等。
|
||||
|
||||
`deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。
|
||||
|
||||
## 阻塞
|
||||
|
||||
压缩通过日志记录的锁串行化:`compactRegion` 会拒绝启动,条件是最后一个 `compact/start` 之后没有匹配的 `compact/end`。锁由日志记录(而非内存 mutex),因此回放后仍然有效,持久化后端也可以在重新加载时检测遗留 `compact/start`。锁会覆盖**整个**操作:摘要、`compact/summary` 溯源记录*以及* `user/message` 表层替换全部发生在 `compact/end` 之前,因此 `session/event` listener 即使在 `compact/end` 时触发,也绝不会看到锁已释放而表层变更仍在等待。基础后端会在摘要后重新验证已选表层:表层变更会导致拒绝,不相关的仅日志追加不会使替换失效。即使摘要抛出异常,也会追加 `compact/end`,因此失败绝不会将锁卡死。
|
||||
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compact/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态。活动标记对不能跨越 `turn/start` 或 `turn/end`;在接管会话时,如果后续 end-seed 证明打开的标记对已经陈旧,则继承前缀中的修复边界仍可回放。
|
||||
|
||||
锁就是持久标记对,而非 `WeakSet`、包装层 mutex 或客户端侧锚点。`compact/start` 会在摘要让出控制权之前同步追加。之后每次失败都会恰好尝试一次 `compact/end { error }`;如果追加该闭合事件本身失败,未匹配 start 会继续作为有意保留的 busy 信号,并且不会尝试 flush。已成功闭合的手动尝试即使报告 `changed` 或 `summary` 也会 flush,从而在释放轮次接纳预留前保留该记录。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -57,7 +64,11 @@
|
||||
|
||||
## 实现后端
|
||||
|
||||
继承 `CompactService`,实现 `compactIfNeeded` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
|
||||
继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
|
||||
|
||||
## 在 host 程序之外识别检查点(`./checkpoint`)
|
||||
|
||||
`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 会让那边编译失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -77,6 +88,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **尚无面向模型的消费方层**:`@deepseek-ai/dsh-tool-compact`(`/compact` 工具)已暂缓;只能通过直接 `ctx.compact` 调用或后端的自动 listener 进行压缩。
|
||||
- **面向用户的命令,而非模型工具**:`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具。
|
||||
- **部分单元溢出不在契约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
|
||||
- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./checkpoint": {
|
||||
"types": "./lib/types/checkpoint.d.ts",
|
||||
"default": "./lib/types/checkpoint.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
27
packages/compact/compact/src/checkpoint.ts
Normal file
27
packages/compact/compact/src/checkpoint.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* `dsh-commands/brand` shape.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact/checkpoint
|
||||
*/
|
||||
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
|
||||
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* Test whether a persisted message source identifies a compaction checkpoint.
|
||||
* @param source - source restored from a surface user message.
|
||||
* @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
|
||||
}
|
||||
@@ -8,34 +8,60 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.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)
|
||||
|
||||
/**
|
||||
* Test whether a persisted message source identifies a compaction checkpoint.
|
||||
* @param source - source restored from a surface user message.
|
||||
* @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
|
||||
}
|
||||
// The checkpoint source and its 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'
|
||||
|
||||
/** Why automatic policy is asking a backend to consider compaction. */
|
||||
export type CompactionTrigger = 'pressure' | 'context-overflow'
|
||||
|
||||
/** Expected failure classes for an explicit idle-session compaction request. */
|
||||
export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence'
|
||||
|
||||
/**
|
||||
* Expected manual-compaction failure suitable for a direct human-command result.
|
||||
* Shared durable-lock entry assertions may also throw the `busy` subtype from
|
||||
* automatic compaction paths.
|
||||
*/
|
||||
export class ManualCompactionError extends Error {
|
||||
override readonly name = 'ManualCompactionError'
|
||||
|
||||
/**
|
||||
* Create one classified compaction failure.
|
||||
* @param code - stable failure class; `busy` may originate from any compaction entry path.
|
||||
* @param message - backend diagnostic retained as the Error message.
|
||||
* @param options - optional original failure.
|
||||
*/
|
||||
constructor(
|
||||
readonly code: ManualCompactionErrorCode,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
session: Session
|
||||
options: { provider?: string; model?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent capability required to serialize an explicit idle-session compaction
|
||||
* against driver turns. The durable `compact/start` marker separately excludes
|
||||
* other compaction transactions.
|
||||
*/
|
||||
export interface ManualCompactAgentContext extends CompactAgentContext {
|
||||
reserveTurnAdmission(): (() => void) | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
compact: CompactService
|
||||
@@ -74,6 +100,29 @@ export abstract class CompactService extends Service {
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
|
||||
/**
|
||||
* Explicitly compact useful history even below automatic pressure thresholds.
|
||||
* Implementations reserve idle turn admission synchronously before any
|
||||
* asynchronous work, select a useful range without writing on a no-op, then
|
||||
* append a standalone `compact/start` before summarization. That durable
|
||||
* marker is the compaction lock until one `compact/end` attempt. Later waking
|
||||
* prompts remain accepted in FIFO order and start only after the optional
|
||||
* durability checkpoint and admission release. Context injected while the
|
||||
* summary runs may sit between the marker pair; only the selected span must
|
||||
* remain stable.
|
||||
*
|
||||
* @param agent - idle agent whose durable history should be compacted.
|
||||
* @param signal - command-owned cancellation forwarded to summarization.
|
||||
* @returns the compaction result, or `null` when no safe useful range exists.
|
||||
* @throws {@link ManualCompactionError} for expected busy, changed-span,
|
||||
* summarization/shrink, commit-stage, or persistence failures, and the exact
|
||||
* abort reason when cancelled. Failed attempts remain visible in the log.
|
||||
*/
|
||||
abstract compactNow(
|
||||
agent: ManualCompactAgentContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
|
||||
/**
|
||||
* Forcibly compact a range of surface nodes into a single summary node.
|
||||
* `start` and `end` name an inclusive span by surface position, not numeric seq
|
||||
|
||||
@@ -13,7 +13,8 @@ export const name = 'compact-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
interface CompactionTrace {
|
||||
turn: number
|
||||
startSeq: number
|
||||
turn: number | null
|
||||
summarized: boolean
|
||||
}
|
||||
|
||||
@@ -23,9 +24,73 @@ interface SessionTrace {
|
||||
}
|
||||
|
||||
type CompactionTransition =
|
||||
| { kind: 'start'; turn: number }
|
||||
| { kind: 'summary'; turn: number }
|
||||
| { kind: 'start'; startSeq: number; turn: number | null }
|
||||
| { kind: 'summary'; startSeq: number; turn: number | null }
|
||||
| { kind: 'end' }
|
||||
| { kind: 'end-seed' }
|
||||
|
||||
/** Compaction starts still unmatched when a later seed boundary made them stale. */
|
||||
function inheritedOrphanStartSeqs(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlySet<number> {
|
||||
const stale = new Set<number>()
|
||||
let openStartSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'compact/start') {
|
||||
openStartSeq = event.seq
|
||||
} else if (event.type === 'compact/end') {
|
||||
openStartSeq = undefined
|
||||
} else if (event.type === 'session/end-seed') {
|
||||
if (openStartSeq !== undefined) stale.add(openStartSeq)
|
||||
openStartSeq = undefined
|
||||
}
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
/** Keep every live compaction bracket on one side of each turn boundary. */
|
||||
function validateTurnBoundary(
|
||||
trace: SessionTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (
|
||||
(event.type !== 'turn/start' && event.type !== 'turn/end')
|
||||
|| trace.compaction === undefined
|
||||
) return
|
||||
const owner = trace.compaction.turn === null
|
||||
? 'standalone compaction'
|
||||
: `compaction for turn ${trace.compaction.turn}`
|
||||
fail(`${event.type} cannot cross an open ${owner}`)
|
||||
}
|
||||
|
||||
/** Advance the committed turn cursor after its boundary has been accepted. */
|
||||
function applyTurnBoundary(trace: SessionTrace, event: SessionEvent): boolean {
|
||||
if (event.type === 'turn/start') {
|
||||
trace.openTurn = event.data.turn
|
||||
return true
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
trace.openTurn = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
|
||||
function validateOwner(
|
||||
owner: number | null,
|
||||
openTurn: number | null,
|
||||
eventType: 'compact/start' | 'compact/summary' | 'compact/end',
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (owner === null) {
|
||||
if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`)
|
||||
return
|
||||
}
|
||||
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`)
|
||||
if (owner !== openTurn) fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
|
||||
}
|
||||
|
||||
/** Validate one compaction event without advancing committed trace state. */
|
||||
function validateCompactionEvent(
|
||||
@@ -33,23 +98,22 @@ function validateCompactionEvent(
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): CompactionTransition | undefined {
|
||||
if (event.type === 'session/end-seed') return { kind: 'end-seed' }
|
||||
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') {
|
||||
return undefined
|
||||
}
|
||||
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`)
|
||||
const open = trace.compaction
|
||||
if (event.type === 'compact/start') {
|
||||
if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`)
|
||||
if (event.data.turn !== trace.openTurn) {
|
||||
fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
|
||||
if (open !== undefined) {
|
||||
const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`
|
||||
fail(`compact/start while ${owner} is still compacting`)
|
||||
}
|
||||
return { kind: 'start', turn: event.data.turn }
|
||||
validateOwner(event.data.turn, trace.openTurn, event.type, fail)
|
||||
return { kind: 'start', startSeq: event.seq, turn: event.data.turn }
|
||||
}
|
||||
if (event.type === 'compact/summary') {
|
||||
if (open === undefined) fail('compact/summary has no matching compact/start')
|
||||
if (open.turn !== trace.openTurn) {
|
||||
fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`)
|
||||
}
|
||||
validateOwner(open.turn, trace.openTurn, event.type, fail)
|
||||
if (open.summarized) fail('compact/summary repeated within one compaction')
|
||||
const seqs = event.data.shadowedSeqs
|
||||
if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty')
|
||||
@@ -59,15 +123,13 @@ 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', turn: open.turn }
|
||||
return { kind: 'summary', startSeq: open.startSeq, turn: open.turn }
|
||||
}
|
||||
if (open === undefined) fail('compact/end has no matching compact/start')
|
||||
if (event.data.turn !== open.turn) {
|
||||
fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`)
|
||||
}
|
||||
if (event.data.turn !== trace.openTurn) {
|
||||
fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
|
||||
fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`)
|
||||
}
|
||||
validateOwner(open.turn, trace.openTurn, event.type, fail)
|
||||
if (event.data.error === undefined && !open.summarized) {
|
||||
fail('successful compact/end requires one compact/summary')
|
||||
}
|
||||
@@ -78,8 +140,20 @@ function validateCompactionEvent(
|
||||
function applyCompactionTransition(
|
||||
transition: CompactionTransition,
|
||||
): CompactionTrace | undefined {
|
||||
if (transition.kind === 'start') return { turn: transition.turn, summarized: false }
|
||||
if (transition.kind === 'summary') return { turn: transition.turn, summarized: true }
|
||||
if (transition.kind === 'start') {
|
||||
return {
|
||||
startSeq: transition.startSeq,
|
||||
turn: transition.turn,
|
||||
summarized: false,
|
||||
}
|
||||
}
|
||||
if (transition.kind === 'summary') {
|
||||
return {
|
||||
startSeq: transition.startSeq,
|
||||
turn: transition.turn,
|
||||
summarized: true,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -92,11 +166,20 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
const seed = (session: Session): SessionTrace => {
|
||||
const trace: SessionTrace = { openTurn: null, compaction: undefined }
|
||||
traces.set(session, trace)
|
||||
const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events)
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'turn/start') trace.openTurn = event.data.turn
|
||||
else if (event.type === 'turn/end') trace.openTurn = null
|
||||
// Constructor-seed repair boundaries can precede the end-seed marker
|
||||
// that proves an inherited orphan stale. Replay that inherited prefix
|
||||
// without letting the soon-to-be-cleared bracket veto its repair.
|
||||
if (
|
||||
trace.compaction === undefined
|
||||
|| !staleOrphanStartSeqs.has(trace.compaction.startSeq)
|
||||
) {
|
||||
validateTurnBoundary(trace, event, fail)
|
||||
}
|
||||
const transition = validateCompactionEvent(trace, event, fail)
|
||||
if (transition !== undefined) trace.compaction = applyCompactionTransition(transition)
|
||||
applyTurnBoundary(trace, event)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
@@ -106,15 +189,12 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const trace = traceFor(session)
|
||||
if (event.type === 'turn/start') {
|
||||
trace.openTurn = event.data.turn
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
trace.openTurn = null
|
||||
return
|
||||
}
|
||||
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return
|
||||
validateTurnBoundary(trace, event, fail)
|
||||
if (applyTurnBoundary(trace, event)) return
|
||||
if (event.type !== 'session/end-seed'
|
||||
&& event.type !== 'compact/start'
|
||||
&& event.type !== 'compact/summary'
|
||||
&& event.type !== 'compact/end') return
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next -- internal/dispatch stages every compaction event */
|
||||
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')
|
||||
@@ -124,7 +204,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const transition = validateCompactionEvent(traceFor(session), event, fail)
|
||||
const trace = traceFor(session)
|
||||
validateTurnBoundary(trace, event, fail)
|
||||
const transition = validateCompactionEvent(trace, event, fail)
|
||||
if (transition !== undefined) staged.set(event, { session, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
@@ -11,8 +11,12 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
|
||||
'compact/start': { turn: number }
|
||||
/**
|
||||
* Marks the start of a compaction — log-only, holds the lock until
|
||||
* `compact/end`. A numbered owner is strictly enclosed by that open turn;
|
||||
* `null` identifies a standalone manual transaction between turns.
|
||||
*/
|
||||
'compact/start': { turn: number | null }
|
||||
/**
|
||||
* Provenance record of a completed summarization — log-only, no surfaceOp.
|
||||
* The summary content is in `data.summary`; the actual surface replacement
|
||||
@@ -40,8 +44,11 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/** Provider-reported token usage for the summarization request, when emitted. */
|
||||
usage?: TokenUsage
|
||||
}
|
||||
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
|
||||
'compact/end': { turn: number; error?: string }
|
||||
/**
|
||||
* Marks the end of a compaction — log-only, releases the lock. Its owner
|
||||
* matches `compact/start`; `error` records an unsuccessful attempt.
|
||||
*/
|
||||
'compact/end': { turn: number | null; error?: string }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||
import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||
|
||||
/**
|
||||
* A trivial concrete CompactService implementing the abstract contract. The
|
||||
@@ -29,6 +30,14 @@ class StubCompactService extends CompactService {
|
||||
return null
|
||||
}
|
||||
|
||||
override async compactNow(
|
||||
_agent: ManualCompactAgentContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
this.lastSignal = signal
|
||||
return null
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
start: number,
|
||||
end: number,
|
||||
@@ -98,6 +107,12 @@ describe('CompactService seam', () => {
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
|
||||
const signal = new AbortController().signal
|
||||
expect(await svc.compactNow({
|
||||
...stubAgent(session),
|
||||
reserveTurnAdmission: () => () => undefined,
|
||||
}, signal)).toBeNull()
|
||||
expect(svc.lastSignal).toBe(signal)
|
||||
})
|
||||
|
||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||
|
||||
@@ -41,6 +41,103 @@ describe('compaction invariants', () => {
|
||||
failed.append('compact/end', { turn: 2, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('accepts standalone successful and failed compaction lifecycles between turns', async () => {
|
||||
const ctx = await setup()
|
||||
const success = ctx.sessions.create()
|
||||
success.append('compact/start', { turn: null })
|
||||
success.append('compact/summary', summary())
|
||||
success.append('compact/end', { turn: null })
|
||||
|
||||
const failed = ctx.sessions.create()
|
||||
failed.append('compact/start', { turn: null })
|
||||
failed.append('compact/end', { turn: null, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('clears an inherited open compaction trace at end-seed during replay', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type))
|
||||
.toEqual(['compact/start', 'session/end-seed'])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
|
||||
expect(() => {
|
||||
replayed.append('compact/start', { turn: null })
|
||||
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-numbered-compaction-source'))
|
||||
startTurn(source)
|
||||
source.append('compact/start', { turn: 1 })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type))
|
||||
.toEqual(['turn/start', 'compact/start', 'session/end-seed'])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
|
||||
expect(() => replayed.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'interrupted' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-repaired-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type)).toEqual([
|
||||
'compact/start',
|
||||
'turn/start',
|
||||
'turn/end',
|
||||
'session/end-seed',
|
||||
])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).resolves.toBeUndefined()
|
||||
|
||||
expect(() => {
|
||||
startTurn(replayed, 2)
|
||||
replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('closed-nested-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
|
||||
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined))
|
||||
.rejects.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
})
|
||||
|
||||
it('rebuilds an open trace when the companion loads after the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -78,6 +175,26 @@ describe('compaction invariants', () => {
|
||||
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
|
||||
})
|
||||
|
||||
it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => {
|
||||
const ctx = await setup()
|
||||
const open = ctx.sessions.create()
|
||||
startTurn(open)
|
||||
expect(() => open.append('compact/start', { turn: null }))
|
||||
.toThrow(/standalone but turn 1 is open/)
|
||||
|
||||
const idle = ctx.sessions.create()
|
||||
expect(() => idle.append('compact/start', { turn: 1 }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('attributes a nested standalone start to the standalone owner', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('compact/start', { turn: null })
|
||||
expect(() => session.append('compact/start', { turn: null }))
|
||||
.toThrow(/standalone compaction is still compacting/)
|
||||
})
|
||||
|
||||
it('rejects an unenclosed compaction event when replaying an existing session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -89,23 +206,30 @@ describe('compaction invariants', () => {
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects an open compaction that crosses into another turn', async () => {
|
||||
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
|
||||
const ctx = await setup()
|
||||
const summarySession = ctx.sessions.create()
|
||||
startTurn(summarySession)
|
||||
summarySession.append('compact/start', { turn: 1 })
|
||||
summarySession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
startTurn(summarySession, 2)
|
||||
expect(() => summarySession.append('compact/summary', summary()))
|
||||
.toThrow(/belongs to turn 1 but open turn is 2/)
|
||||
const standalone = ctx.sessions.create()
|
||||
standalone.append('compact/start', { turn: null })
|
||||
expect(() => { startTurn(standalone) })
|
||||
.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
standalone.append('compact/end', { turn: null, error: 'cancelled' })
|
||||
expect(() => {
|
||||
startTurn(standalone)
|
||||
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const endSession = ctx.sessions.create()
|
||||
startTurn(endSession)
|
||||
endSession.append('compact/start', { turn: 1 })
|
||||
endSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
startTurn(endSession, 2)
|
||||
expect(() => endSession.append('compact/end', { turn: 1, error: 'late' }))
|
||||
.toThrow(/names turn 1 but open turn is 2/)
|
||||
const numbered = ctx.sessions.create()
|
||||
startTurn(numbered)
|
||||
numbered.append('compact/start', { 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' })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
Reference in New Issue
Block a user