refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -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/compaction/compaction-basic/README.md
|
||||
README.md: 227bc64ff9e8ff27b757ca3900aba743bf11776d
|
||||
README.zh.md: 93a17a29be256ac4d907790ad6f974aa7554ae48
|
||||
@@ -0,0 +1,164 @@
|
||||
# @deepseek-ai/dsh-compaction-basic
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactionEngine` implementing the `@deepseek-ai/dsh-compaction` Service Definition with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`).
|
||||
|
||||
This package owns the Service provider role of the compaction capability — see the [Service Definition package](../compaction/README.md) for its contract and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
## What it owns
|
||||
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPruner`](../compaction-tool-result-pruner/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compaction` boundary helpers](../compaction/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` extension point. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the `compaction/summary` event, and later automatic cycles merge the prior checkpoint.
|
||||
- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compaction/start` synchronously, prepares and awaits the summary, revalidates, appends `compaction/summary` plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; the serial `agent/pre-step` listener checks pressure before request derivation, while canonical provider overflow enters through `agent/request-error` and authorizes retry only after durable surface progress. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
|
||||
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — a live unmatched `compaction/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, cited source events, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compaction/summary`.
|
||||
|
||||
## Config (`BasicCompactionConfig`)
|
||||
|
||||
Every setting is optional. Top-level policy fields are defaults for every routed model; `modelPolicies` applies partial overrides to exact provider/model pairs. At pressure time, compaction-basic asks the owning LLM adapter for that route's context capacity and resolves absolute budgets. Unrecognized keys, duplicate targets, mutually exclusive retention forms, and a merged `retainRatio` that is not below `thresholdRatio` fail plugin load. An absolute `retainTokens` budget that is not below its scaled threshold fails on the first resolvable target because that comparison requires model capacity.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(routedContextWindow × ratio)`. |
|
||||
| `retainRatio` | no (default `0.16`) | Recent surface budget kept verbatim as a fraction of the routed context window; mutually exclusive with `retainTokens`. |
|
||||
| `retainTokens` | no | Absolute recent surface budget kept verbatim; mutually exclusive with `retainRatio` and must be below the resolved threshold. |
|
||||
| `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
|
||||
| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. |
|
||||
| `auto` | no (default `true`) | Register step-boundary pressure and overflow-recovery listeners. Set `false` for manual-only. |
|
||||
|
||||
Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry.
|
||||
|
||||
An adapter may return no capacity for a valid dynamic route, and resolved capacity may expose an invalid absolute retention budget. Manual pressure checks then throw a target-specific configuration error; the automatic listener warns once for that exact target and continues with full history. Unrelated operational failures remain independently visible. Canonical provider overflow still attempts recovery because the provider has already established that compaction is necessary.
|
||||
|
||||
## Usage
|
||||
|
||||
`BasicCompactionEngine` 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 '@deepseek-ai/cordis'
|
||||
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
export const name = 'compaction-basic'
|
||||
export const inject = ['llm']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(TokenMeter)
|
||||
ctx.plugin(BasicCompactionEngine)
|
||||
}
|
||||
```
|
||||
|
||||
Loading the plugin registers `ctx.compaction`. Add [`dsh-compaction-tool-result-pruner`](../compaction-tool-result-pruner/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.compaction.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:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-compaction-basic'
|
||||
config:
|
||||
thresholdRatio: 0.8
|
||||
retainRatio: 0.16
|
||||
modelPolicies:
|
||||
- provider: local
|
||||
model: small-context
|
||||
thresholdRatio: 0.7
|
||||
retainTokens: 2048
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Conversation history
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface. A checkpoint replaces the selected older range and is followed by the retained recent units.
|
||||
|
||||
##### Conversation checkpoint preamble
|
||||
|
||||
```markdown
|
||||
This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. The replacement reduces future input history rather than appending a second copy. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable.
|
||||
|
||||
### Auxiliary summarizer request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
|
||||
##### Compaction instruction (final user message)
|
||||
|
||||
```markdown
|
||||
You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.
|
||||
|
||||
Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.
|
||||
|
||||
## Primary Request and Intent
|
||||
- [the user's original and evolving goals; quote verbatim where the exact wording matters]
|
||||
|
||||
## Key Technical Concepts
|
||||
- [technologies, frameworks, patterns, and conventions in play]
|
||||
|
||||
## Files and Code
|
||||
- [exact path: why it matters, key changes or snippets]
|
||||
|
||||
## Errors and Fixes
|
||||
- [error: how it was resolved, plus any related user feedback]
|
||||
|
||||
## Pending Jobs
|
||||
- [explicitly requested work not yet completed]
|
||||
|
||||
## Current Work
|
||||
- [precisely what was in progress at this checkpoint]
|
||||
|
||||
## Next Step
|
||||
- [the single next action, directly in line with the most recent request, or "(none)"]
|
||||
|
||||
## Critical Context
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- 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.
|
||||
- If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
This is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries can pay this cost more than once.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The replayed system prompt, tools, and shadowed-region messages match the conversation's last routed request byte-for-byte, so the provider's warm prefix cache is reused up to the trailing instruction; only that instruction, and the summary output, is uncached. Routing the summarizer to a different provider/model, or compacting a non-head range, forgoes this reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
|
||||
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
|
||||
- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
|
||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||
- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
|
||||
@@ -0,0 +1,164 @@
|
||||
# @deepseek-ai/dsh-compaction-basic
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**基础压缩(compaction)后端**:`BasicCompactionEngine` 实现 `@deepseek-ai/dsh-compaction` Service Definition,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV Cache(可在 `llm/stream` 处拦截)。
|
||||
|
||||
本包承担压缩能力的 Service provider 角色;其约定见 [Service Definition 包](../compaction/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
|
||||
|
||||
## 拥有的职责
|
||||
|
||||
该后端拥有压缩策略:
|
||||
|
||||
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上,计量最新一份规范化已记录 envelope 与当前表层的 token 用量。因此,步骤边界的压力计量会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering(中途引导)。
|
||||
- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍仅供参考,不参与此处的策略解析。
|
||||
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPruner`](../compaction-tool-result-pruner/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则对已剪枝的表层进行摘要。低于压力的步骤检查绝不剪枝。
|
||||
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compaction` 边界 helper](../compaction/README.md#tool-pairing-boundaries) 将切分点调整到工具调用/结果配对平衡的位置。轮次边界不会保护失控轮次内的旧步骤。尚未闭合且不可分的尾部会在闭合前拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
|
||||
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
|
||||
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent(智能体)目标,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息(包括图片引用),并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用;图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
|
||||
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在 `compaction/summary` 事件上,后续自动周期会合并之前的检查点。
|
||||
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compaction/start`,准备并等待摘要,重新验证,再追加 `compaction/summary` 和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
|
||||
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
|
||||
- **失败处理**:活动的未匹配 `compaction/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
|
||||
|
||||
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、被引用的源事件、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,且必须提供完整的 `rawOutput`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compaction/summary` 上保留这些字段。
|
||||
|
||||
## 配置(`BasicCompactionConfig`)
|
||||
|
||||
所有设置都可选。顶层策略字段是每个已路由模型的默认值;`modelPolicies` 对精确提供方/模型对应用部分覆盖。出现压力时,compaction-basic 会请求所属 LLM(大语言模型)适配器提供该路由的上下文容量,并解析绝对预算。无法识别的配置键、重复目标、互斥保留形式,以及合并后的 `retainRatio` 不低于 `thresholdRatio`,都会使插件加载失败。不低于缩放后阈值的绝对 `retainTokens` 预算会在首次解析出目标时导致失败,因为该比较需要模型容量。
|
||||
|
||||
| Key | 必填 | 含义 |
|
||||
|---|---|---|
|
||||
| `thresholdRatio` | 否(默认 `0.8`) | 在 `floor(routedContextWindow × ratio)` 处压缩。 |
|
||||
| `retainRatio` | 否(默认 `0.16`) | 以已路由上下文窗口的一部分表示逐字保留的近期表层预算;与 `retainTokens` 互斥。 |
|
||||
| `retainTokens` | 否 | 逐字保留的近期表层绝对预算;与 `retainRatio` 互斥,并且必须低于已解析阈值。 |
|
||||
| `summarizationProvider` | 否(默认 `''`) | 与 `summarizationModel` 一起设置;空对会解析为最新已记录请求目标,再回退到 `AgentOptions` 对。 |
|
||||
| `summarizationModel` | 否(默认 `''`) | 与 `summarizationProvider` 一起设置;空对会解析为最新已记录请求目标,再回退到 `AgentOptions` 对。 |
|
||||
| `maxTokens` | 否(默认 `8192`) | 摘要调用的提供方生成上限;可包含推理 token。 |
|
||||
| `compactionRetries` | 否(默认 `1`) | 压力仍高于阈值时,在首次尝试后进行的额外尝试次数。 |
|
||||
| `maxOverflowRetries` | 否(默认 `1`) | 规范上下文窗口溢出后的最大重试次数;`0` 只禁用恢复。 |
|
||||
| `modelPolicies` | 否(默认 `[]`) | 精确的 `{ provider, model, ...partialPolicy }` 覆盖;匹配使用两个字段,不依赖 `listModels()`。 |
|
||||
| `auto` | 否(默认 `true`) | 注册步骤边界压力与溢出恢复 listener。设为 `false` 则仅手动执行。 |
|
||||
|
||||
每个 `modelPolicies` 配置项都接受上述策略字段,但不接受 `auto` 和 `modelPolicies` 自身。如果配置项提供任意一个保留字段,就替换默认策略的保留选择;否则继承保留设置。摘要提供方/模型在每个配置项内仍然成对。
|
||||
|
||||
适配器可能无法为有效动态路由返回容量,已解析容量也可能暴露无效的绝对保留预算。此时手动压力检查会抛出目标特定配置错误;自动 listener 会对该精确目标警告一次,并携带完整历史继续。不相关的操作性失败仍会独立可见。规范提供方溢出仍会尝试恢复,因为提供方已确立压缩的必要性。
|
||||
|
||||
## 用法
|
||||
|
||||
`BasicCompactionEngine` 需要 `ctx.llm`、`ctx.tokenMeter` 和 `ctx.sessions`。以下组合从其宿主接收 `ctx.llm`,并安装另外两项服务:
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
export const name = 'compaction-basic'
|
||||
export const inject = ['llm']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SessionStore)
|
||||
ctx.plugin(TokenMeter)
|
||||
ctx.plugin(BasicCompactionEngine)
|
||||
}
|
||||
```
|
||||
|
||||
加载插件会注册 `ctx.compaction`。在该插件之前添加同级 [`dsh-compaction-tool-result-pruner`](../compaction-tool-result-pruner/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩。同级 [`dsh-command-compact`](../command-compact/README.md) 调用 `ctx.compaction.compactNow(...)`;编程调用方也可以直接使用任一 seam 操作。
|
||||
|
||||
例如,同一个压缩插件可以安全服务于容量不同的模型,并应用一项目标特定策略:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-compaction-basic'
|
||||
config:
|
||||
thresholdRatio: 0.8
|
||||
retainRatio: 0.16
|
||||
modelPolicies:
|
||||
- provider: local
|
||||
model: small-context
|
||||
thresholdRatio: 0.7
|
||||
retainTokens: 2048
|
||||
```
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 会话历史
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
成功步骤越过阈值后,如果已加载可选 pruner,超大工具结果会先被改写。如果仍需摘要,下一个请求会收到下方检查点前导、一个空行、`<compacted-summary>`、根据数据生成的摘要以及 `</compacted-summary>`。溢出恢复会根据使表层前进的任何替换重建立即重试。检查点会替换已选较早范围,后面跟随已保留的近期单元。
|
||||
|
||||
##### 会话检查点前导
|
||||
|
||||
```markdown
|
||||
This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
不依赖模型的剪枝可以完全避免辅助调用;否则它会在摘要替换较早范围之前缩减该调用的 transcript(文本记录)。替换会缩减未来输入历史,而非追加第二份副本。摘要会保留到后续压缩将其替换,但不可分的非工具单元仍可能超出预算。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
它是替换,而非仅追加。每个检查点都会使从第一个已替换历史 token 起的复用失效;该范围之前未更改的请求前缀仍可复用。
|
||||
|
||||
### 辅助摘要器请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
摘要模型会接收逐字回放的会话:与上次已路由请求为已遮蔽区域发送的相同系统提示词、工具 schema 与消息,后面跟随一条最终 user 消息,即下方压缩指令。会话模型绝不会看到该私有请求或其推理;只有返回文本会被存储。
|
||||
|
||||
##### 压缩指令(最终 user 消息)
|
||||
|
||||
```markdown
|
||||
You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.
|
||||
|
||||
Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.
|
||||
|
||||
## Primary Request and Intent
|
||||
- [the user's original and evolving goals; quote verbatim where the exact wording matters]
|
||||
|
||||
## Key Technical Concepts
|
||||
- [technologies, frameworks, patterns, and conventions in play]
|
||||
|
||||
## Files and Code
|
||||
- [exact path: why it matters, key changes or snippets]
|
||||
|
||||
## Errors and Fixes
|
||||
- [error: how it was resolved, plus any related user feedback]
|
||||
|
||||
## Pending Jobs
|
||||
- [explicitly requested work not yet completed]
|
||||
|
||||
## Current Work
|
||||
- [precisely what was in progress at this checkpoint]
|
||||
|
||||
## Next Step
|
||||
- [the single next action, directly in line with the most recent request, or "(none)"]
|
||||
|
||||
## Critical Context
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- 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.
|
||||
- If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
这是一次独立模型调用:输入是已回放会话前缀加固定指令,输出受 `maxTokens` 限制。收敛重试可能多次支付这项成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
已回放系统提示词、工具与已遮蔽区域消息与会话最后一个已路由请求逐字匹配,因此提供方的热前缀 cache 可复用至尾随指令之前;只有该指令与摘要输出未缓存。将摘要器路由到不同提供方/模型,或压缩非头部范围,都会放弃该复用。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **计量准确度取决于固定启发式规则**:可复用提供方用量缺失时,会回退到字符数加结构开销,而非精确的 token 化。
|
||||
- **溢出分类由适配器维护**:提供方措辞可能改变;两个 DeepSeek 适配器将当前可识别的上下文限制失败规范化为 `CONTEXT_WINDOW_EXCEEDED`。
|
||||
- **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**:恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。
|
||||
- **`compactRegion` 要求存在未结束的轮次**:在完全关闭的会话上手动调用会抛出异常(「no open turn」),而不是执行压缩。
|
||||
- **摘要失败会保留最新持久表层**:任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。因达到 `maxTokens` 而发生的摘要截断(隐藏推理 token 可能会耗尽该额度)遵循同一规则。
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compaction-basic",
|
||||
"description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/compaction/compaction-basic"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-compaction-tool-result-pruner": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Load-time validation and routed-model policy resolution for compaction-basic.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compaction-basic/config
|
||||
*/
|
||||
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
BasicCompactionConfig,
|
||||
CompactionPolicyConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
ResolvedCompactSpec,
|
||||
ResolvedConfig,
|
||||
ResolvedRetention,
|
||||
ResolvedTargetPolicy,
|
||||
} from './types.ts'
|
||||
|
||||
/** Default request-pressure fraction for every routed model. */
|
||||
const DEFAULT_THRESHOLD_RATIO = 0.8
|
||||
|
||||
/** Default verbatim-tail fraction for every routed model. */
|
||||
const DEFAULT_RETAIN_RATIO = 0.16
|
||||
|
||||
/** Fields shared by top-level defaults and exact-target overrides. */
|
||||
const POLICY_CONFIG_KEYS = [
|
||||
'thresholdRatio',
|
||||
'retainRatio',
|
||||
'retainTokens',
|
||||
'summarizationProvider',
|
||||
'summarizationModel',
|
||||
'maxTokens',
|
||||
'compactionRetries',
|
||||
'maxOverflowRetries',
|
||||
] as const
|
||||
|
||||
/** Complete public top-level configuration key set. */
|
||||
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
...POLICY_CONFIG_KEYS,
|
||||
'modelPolicies',
|
||||
'auto',
|
||||
])
|
||||
|
||||
/** Complete exact-target override key set. */
|
||||
const MODEL_POLICY_KEYS: ReadonlySet<string> = new Set([
|
||||
'provider',
|
||||
'model',
|
||||
...POLICY_CONFIG_KEYS,
|
||||
])
|
||||
|
||||
/** Target-specific pressure configuration failure eligible for warning suppression. */
|
||||
export class TargetPressureConfigError extends Error {
|
||||
/**
|
||||
* @param targetKey - exact provider/model route used as the warning key.
|
||||
* @param message - actionable configuration failure detail.
|
||||
*/
|
||||
constructor(readonly targetKey: string, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate service defaults plus exact-target partial overrides.
|
||||
* @param config - untrusted plugin configuration after Loader normalization.
|
||||
* @returns detached immutable defaults and validated exact-target overrides.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactionConfig = {}): ResolvedConfig {
|
||||
validateKeys(config, BASIC_COMPACT_CONFIG_KEYS, 'BasicCompactionConfig')
|
||||
validatePolicy(config, 'BasicCompactionConfig')
|
||||
if (config.auto !== undefined && typeof config.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactionConfig: auto must be a boolean')
|
||||
}
|
||||
|
||||
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||
const retention = resolveRetention(config, { retainRatio: DEFAULT_RETAIN_RATIO })
|
||||
validateRatioRetention(thresholdRatio, retention, 'BasicCompactionConfig')
|
||||
const modelPolicies = resolveModelPolicies(config.modelPolicies)
|
||||
for (const [index, policy] of modelPolicies.entries()) {
|
||||
validateRatioRetention(
|
||||
policy.thresholdRatio ?? thresholdRatio,
|
||||
resolveRetention(policy, retention),
|
||||
`BasicCompactionConfig: modelPolicies[${index}]`,
|
||||
)
|
||||
}
|
||||
|
||||
return deepFreeze({
|
||||
thresholdRatio,
|
||||
...retention,
|
||||
summarizationProvider: config.summarizationProvider ?? '',
|
||||
summarizationModel: config.summarizationModel ?? '',
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
compactionRetries: config.compactionRetries ?? 1,
|
||||
maxOverflowRetries: config.maxOverflowRetries ?? 1,
|
||||
modelPolicies,
|
||||
auto: config.auto ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the exact provider/model override over the validated default policy.
|
||||
* @param config - validated service defaults and override table.
|
||||
* @param target - exact durable provider/model route to match.
|
||||
* @returns detached immutable policy before model-capacity scaling.
|
||||
*/
|
||||
export function resolveTargetPolicy(
|
||||
config: ResolvedConfig,
|
||||
target: Pick<LlmCallConfig, 'provider' | 'model'>,
|
||||
): ResolvedTargetPolicy {
|
||||
const override = config.modelPolicies.find(policy => (
|
||||
policy.provider === target.provider && policy.model === target.model
|
||||
))
|
||||
const inheritedRetention: ResolvedRetention = config.retainTokens === undefined
|
||||
? { retainRatio: config.retainRatio }
|
||||
: { retainTokens: config.retainTokens }
|
||||
return deepFreeze({
|
||||
target: { provider: target.provider, model: target.model },
|
||||
thresholdRatio: override?.thresholdRatio ?? config.thresholdRatio,
|
||||
...resolveRetention(override ?? {}, inheritedRetention),
|
||||
summarizationProvider: override?.summarizationProvider ?? config.summarizationProvider,
|
||||
summarizationModel: override?.summarizationModel ?? config.summarizationModel,
|
||||
maxTokens: override?.maxTokens ?? config.maxTokens,
|
||||
compactionRetries: override?.compactionRetries ?? config.compactionRetries,
|
||||
maxOverflowRetries: override?.maxOverflowRetries ?? config.maxOverflowRetries,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale one routed policy into concrete token budgets for its model capacity.
|
||||
* @param policy - merged policy for the exact routed target.
|
||||
* @param contextWindow - positive adapter-owned capacity for that target.
|
||||
* @returns detached immutable pressure and retention budgets.
|
||||
*/
|
||||
export function resolveCompactSpec(
|
||||
policy: ResolvedTargetPolicy,
|
||||
contextWindow: number,
|
||||
): ResolvedCompactSpec {
|
||||
const targetKey = `${policy.target.provider}/${policy.target.model}`
|
||||
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||
throw new TargetPressureConfigError(
|
||||
targetKey,
|
||||
`BasicCompactionConfig: contextWindow (${contextWindow}) must be a positive integer`,
|
||||
)
|
||||
}
|
||||
const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio)
|
||||
const retainTokens = policy.retainTokens === undefined
|
||||
? Math.floor(contextWindow * policy.retainRatio)
|
||||
: policy.retainTokens
|
||||
if (retainTokens >= thresholdTokens) {
|
||||
throw new TargetPressureConfigError(
|
||||
targetKey,
|
||||
`BasicCompactionConfig: ${policy.target.provider}/${policy.target.model} retainTokens `
|
||||
+ `(${retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||
)
|
||||
}
|
||||
return deepFreeze({
|
||||
target: { ...policy.target },
|
||||
contextWindow,
|
||||
thresholdRatio: policy.thresholdRatio,
|
||||
thresholdTokens,
|
||||
retainTokens,
|
||||
summarizationProvider: policy.summarizationProvider,
|
||||
summarizationModel: policy.summarizationModel,
|
||||
maxTokens: policy.maxTokens,
|
||||
compactionRetries: policy.compactionRetries,
|
||||
maxOverflowRetries: policy.maxOverflowRetries,
|
||||
})
|
||||
}
|
||||
|
||||
/** Choose an explicit retention form or inherit the already-resolved fallback. */
|
||||
function resolveRetention(
|
||||
config: CompactionPolicyConfig,
|
||||
fallback: ResolvedRetention,
|
||||
): ResolvedRetention {
|
||||
if (config.retainTokens !== undefined) return { retainTokens: config.retainTokens }
|
||||
if (config.retainRatio !== undefined) return { retainRatio: config.retainRatio }
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Reject a capacity-independent retention conflict at plugin load. */
|
||||
function validateRatioRetention(
|
||||
thresholdRatio: number,
|
||||
retention: ResolvedRetention,
|
||||
name: string,
|
||||
): void {
|
||||
if (retention.retainRatio !== undefined && retention.retainRatio >= thresholdRatio) {
|
||||
throw new Error(
|
||||
`${name}: retainRatio (${retention.retainRatio}) must be less than `
|
||||
+ `the resolved thresholdRatio (${thresholdRatio})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate, detach, and reject duplicate exact-target policies. */
|
||||
function resolveModelPolicies(configured: unknown): ModelCompactPolicyConfig[] {
|
||||
if (configured === undefined) return []
|
||||
if (!Array.isArray(configured)) {
|
||||
throw new Error('BasicCompactionConfig: modelPolicies must be an array')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
return configured.map((source: unknown, index) => {
|
||||
const name = `BasicCompactionConfig: modelPolicies[${index}]`
|
||||
assertModelPolicy(source, name)
|
||||
const key = `${source.provider}\u0000${source.model}`
|
||||
if (seen.has(key)) {
|
||||
throw new Error(
|
||||
`BasicCompactionConfig: duplicate model policy for ${source.provider}/${source.model}`,
|
||||
)
|
||||
}
|
||||
seen.add(key)
|
||||
return { ...source }
|
||||
})
|
||||
}
|
||||
|
||||
/** Validate one untrusted exact-target override and narrow its public type. */
|
||||
function assertModelPolicy(
|
||||
source: unknown,
|
||||
name: string,
|
||||
): asserts source is ModelCompactPolicyConfig {
|
||||
if (!isUnknownRecord(source)) throw new Error(`${name} must be an object`)
|
||||
validateKeys(source, MODEL_POLICY_KEYS, name)
|
||||
assertNonEmptyString(`${name}.provider`, source.provider)
|
||||
assertNonEmptyString(`${name}.model`, source.model)
|
||||
validatePolicy(source, name)
|
||||
}
|
||||
|
||||
/** Validate the fields common to defaults and exact-target partial overrides. */
|
||||
function validatePolicy(
|
||||
config: CompactionPolicyConfig | Record<string, unknown>,
|
||||
name: string,
|
||||
): void {
|
||||
const thresholdRatio = config.thresholdRatio
|
||||
const retainRatio = config.retainRatio
|
||||
const retainTokens = config.retainTokens
|
||||
const maxTokens = config.maxTokens
|
||||
const compactionRetries = config.compactionRetries
|
||||
const maxOverflowRetries = config.maxOverflowRetries
|
||||
if (thresholdRatio !== undefined) assertRatio(`${name}.thresholdRatio`, thresholdRatio)
|
||||
if (retainRatio !== undefined) assertRatio(`${name}.retainRatio`, retainRatio)
|
||||
if (retainTokens !== undefined) assertNonNegativeInteger(`${name}.retainTokens`, retainTokens)
|
||||
if (retainRatio !== undefined && retainTokens !== undefined) {
|
||||
throw new Error(`${name}: retainRatio and retainTokens are mutually exclusive`)
|
||||
}
|
||||
if (maxTokens !== undefined) assertPositiveInteger(`${name}.maxTokens`, maxTokens)
|
||||
if (compactionRetries !== undefined) {
|
||||
assertNonNegativeInteger(`${name}.compactionRetries`, compactionRetries)
|
||||
}
|
||||
if (maxOverflowRetries !== undefined) {
|
||||
assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries)
|
||||
}
|
||||
|
||||
validateSummarizationPair(config, name)
|
||||
}
|
||||
|
||||
/** Require one scope to omit, clear, or replace the summarization target as a pair. */
|
||||
function validateSummarizationPair(
|
||||
config: CompactionPolicyConfig | Record<string, unknown>,
|
||||
name: string,
|
||||
): void {
|
||||
const provider = config.summarizationProvider
|
||||
const model = config.summarizationModel
|
||||
if (provider !== undefined && typeof provider !== 'string') {
|
||||
throw new Error(`${name}.summarizationProvider must be a string`)
|
||||
}
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
throw new Error(`${name}.summarizationModel must be a string`)
|
||||
}
|
||||
if (provider === undefined && model === undefined) return
|
||||
if (provider === undefined || model === undefined
|
||||
|| (provider.length === 0) !== (model.length === 0)) {
|
||||
throw new Error(
|
||||
`${name}: summarizationProvider and summarizationModel must be set together `
|
||||
+ 'as an empty or non-empty pair',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||
function validateKeys(config: object, keys: ReadonlySet<string>, name: string): void {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!keys.has(key)) throw new Error(`${name}: unknown key "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertNonEmptyString(name: string, value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${name} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} (${String(value)}) must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${name} (${String(value)}) must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`${name} (${String(value)}) must be a number in (0, 1]`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Basic replay-aware compaction backend.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compaction-basic
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { CompactionEngine, ManualCompactionError } from '@deepseek-ai/dsh-compaction'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compaction'
|
||||
import type { TokenMeter } 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 { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
// Type-only: makes the optional sibling service available to `ctx.get()`.
|
||||
import type {} from '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
||||
import {
|
||||
resolveCompactSpec,
|
||||
resolveConfig,
|
||||
resolveTargetPolicy,
|
||||
TargetPressureConfigError,
|
||||
} from './config.ts'
|
||||
import {
|
||||
assertNoActiveCompaction,
|
||||
compactSurfaceRegion,
|
||||
selectCompactableRange,
|
||||
} from './region.ts'
|
||||
import { summarizeWithLlm } from './summarizer.ts'
|
||||
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
|
||||
import type {
|
||||
BasicCompactionConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
BasicCompactionConfig,
|
||||
CompactionPolicyConfig,
|
||||
ModelCompactPolicyConfig,
|
||||
ResolvedCompactSpec,
|
||||
ResolvedConfig,
|
||||
ResolvedRetention,
|
||||
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,
|
||||
): Pick<LlmCallConfig, 'provider' | 'model'> | undefined {
|
||||
const config = session.requestHeader()?.config
|
||||
if (config === undefined || config.provider.length === 0 || config.model.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return { provider: config.provider, model: config.model }
|
||||
}
|
||||
|
||||
/** Resolve the conversation target used to select an optional policy override. */
|
||||
function conversationTarget(
|
||||
agent: Agent,
|
||||
): Pick<LlmCallConfig, 'provider' | 'model'> | undefined {
|
||||
const routed = routedTarget(agent.session)
|
||||
if (routed !== undefined) return routed
|
||||
if (agent.options.provider === undefined || agent.options.provider.length === 0
|
||||
|| agent.options.model === undefined || agent.options.model.length === 0) return undefined
|
||||
return { provider: agent.options.provider, model: agent.options.model }
|
||||
}
|
||||
|
||||
const thresholdRatioSchema = z.number()
|
||||
const retainRatioSchema = z.number()
|
||||
const retainTokensSchema = z.number().step(1).min(0)
|
||||
const summarizationProviderSchema = z.string()
|
||||
const summarizationModelSchema = z.string()
|
||||
const maxTokensSchema = z.number().step(1).min(1)
|
||||
const compactionRetriesSchema = z.number().step(1).min(0)
|
||||
const maxOverflowRetriesSchema = z.number().step(1).min(0)
|
||||
|
||||
const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
thresholdRatio: thresholdRatioSchema,
|
||||
retainRatio: retainRatioSchema,
|
||||
retainTokens: retainTokensSchema,
|
||||
summarizationProvider: summarizationProviderSchema,
|
||||
summarizationModel: summarizationModelSchema,
|
||||
maxTokens: maxTokensSchema,
|
||||
compactionRetries: compactionRetriesSchema,
|
||||
maxOverflowRetries: maxOverflowRetriesSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
|
||||
* retention, cited source events, and summary-convergence pricing.
|
||||
*
|
||||
* `summarize()` is the sole subclass customization hook; the replay and durable
|
||||
* mutation strategy stays fixed so every pricing decision uses the singleton
|
||||
* token meter.
|
||||
*/
|
||||
export class BasicCompactionEngine extends CompactionEngine {
|
||||
static inject = ['llm', 'tokenMeter', 'sessions']
|
||||
|
||||
static Config: z<BasicCompactionConfig> = z.object({
|
||||
thresholdRatio: thresholdRatioSchema,
|
||||
retainRatio: retainRatioSchema,
|
||||
retainTokens: retainTokensSchema,
|
||||
summarizationProvider: summarizationProviderSchema,
|
||||
summarizationModel: summarizationModelSchema,
|
||||
maxTokens: maxTokensSchema,
|
||||
compactionRetries: compactionRetriesSchema,
|
||||
maxOverflowRetries: maxOverflowRetriesSchema,
|
||||
modelPolicies: z.array(modelPolicy),
|
||||
auto: z.boolean(),
|
||||
})
|
||||
|
||||
/** Resolved and validated compaction configuration. */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
private readonly warnedPressureConfigTargets = new Set<string>()
|
||||
private readonly overflowRetries = new WeakMap<Agent, number>()
|
||||
private readonly overflowAgents = new WeakMap<Session, Agent>()
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactionConfig = {}) {
|
||||
super(ctx)
|
||||
this.config = resolveConfig(config)
|
||||
if (this.config.auto) this._registerAutomaticCompaction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register automatic between-step pressure and model-request overflow
|
||||
* recovery. `compactIfNeeded` stays dynamically dispatched so subclass
|
||||
* overrides are honored at event time.
|
||||
*/
|
||||
private _registerAutomaticCompaction(): void {
|
||||
const { ctx } = this
|
||||
const logResult = (result: CompactionResult, trigger: string): void => {
|
||||
ctx.logger.info(
|
||||
`compaction (${trigger}): shadowed ${result.shadowedSeqs.length} surface nodes `
|
||||
+ `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, `
|
||||
+ `~${result.shadowedTokenCount} tokens)`,
|
||||
)
|
||||
}
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
{ agent, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
if (!signal.aborted) {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||
if (result !== null) logResult(result, 'step pressure')
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TargetPressureConfigError) {
|
||||
if (this.warnedPressureConfigTargets.has(error.targetKey)) return next()
|
||||
this.warnedPressureConfigTargets.add(error.targetKey)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (status === 'idle') this.overflowRetries.delete(agent)
|
||||
})
|
||||
|
||||
// A successful response starts a fresh overflow-recovery sequence even
|
||||
// when tool calls continue the same turn into another request.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'assistant/message') return
|
||||
const agent = this.overflowAgents.get(session)
|
||||
if (agent !== undefined) this.overflowRetries.delete(agent)
|
||||
})
|
||||
|
||||
ctx.on('agent/request-error', async (
|
||||
{ agent, failure, signal },
|
||||
next,
|
||||
) => {
|
||||
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
|
||||
this.overflowAgents.set(agent.session, agent)
|
||||
const target = routedTarget(agent.session)
|
||||
if (target === undefined) return next()
|
||||
const policy = resolveTargetPolicy(this.config, target)
|
||||
const retries = this.overflowRetries.get(agent) ?? 0
|
||||
if (retries >= policy.maxOverflowRetries) return next()
|
||||
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
let result: CompactionResult | null
|
||||
try {
|
||||
result = await this.compactIfNeeded(agent, 'context-overflow', signal)
|
||||
} catch (recoveryError: unknown) {
|
||||
const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
|
||||
// A model-free prune can land before later summary work fails. That
|
||||
// durable reduction is sufficient retry proof; do not discard it just
|
||||
// because the optional second phase threw. Cancellation still wins.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while recovery is awaited.
|
||||
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
|
||||
ctx.logger.warn(
|
||||
`context-overflow compaction failed after durable surface progress: ${message}; `
|
||||
+ 'retrying from the replacement surface',
|
||||
)
|
||||
this.overflowRetries.set(agent, retries + 1)
|
||||
return { kind: 'retry' }
|
||||
}
|
||||
ctx.logger.warn(
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while recovery is awaited.
|
||||
`context-overflow compaction failed: ${message}; ${signal.aborted
|
||||
? 'cancellation prevents retry'
|
||||
: 'preserving the original request error'}`,
|
||||
)
|
||||
return next()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while compaction is awaited.
|
||||
if (signal.aborted
|
||||
|| agent.session.surface.replaceGeneration <= generation) return next()
|
||||
if (result !== null) logResult(result, 'context overflow recovery')
|
||||
this.overflowRetries.set(agent, retries + 1)
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize the replayed conversation region through a direct one-shot
|
||||
* `ctx.llm.stream()` call whose prefix reuses the conversation's own system
|
||||
* prompt, tools, and messages so the provider's KV cache is not invalidated.
|
||||
* Override this sole hook for a template or remote summarizer.
|
||||
* @param input - replayed conversation prefix (system, tools, and leading messages) to condense.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text summary blocks and the exact auxiliary call envelope and output.
|
||||
*/
|
||||
protected async summarize(
|
||||
input: SummarizationInput,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
const target = conversationTarget(agent)
|
||||
const config = target === undefined
|
||||
? this.config
|
||||
: resolveTargetPolicy(this.config, target)
|
||||
return summarizeWithLlm(this.ctx, config, input, agent, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact for replayed step-boundary pressure or one provider-confirmed context
|
||||
* overflow. Both triggers price the latest durable routed request envelope;
|
||||
* overflow bypasses the normal threshold and retained-tail policy so it can
|
||||
* force one useful balanced reduction.
|
||||
* @param agent - agent whose latest durable routed request is measured.
|
||||
* @param trigger - normal step-boundary pressure or context-overflow recovery.
|
||||
* @param signal - live turn cancellation signal forwarded to summarization.
|
||||
* @returns the latest summary compaction result, or `null` when no summary ran.
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
agent: Agent,
|
||||
trigger: CompactionTrigger,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
const target = routedTarget(agent.session)
|
||||
if (target === undefined) return null
|
||||
const policy = resolveTargetPolicy(this.config, target)
|
||||
const meter = this.ctx.tokenMeter
|
||||
let measurement = meter.measure(agent.session)
|
||||
switch (trigger) {
|
||||
case 'context-overflow':
|
||||
break
|
||||
case 'pressure':
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(trigger, 'compaction trigger')
|
||||
}
|
||||
|
||||
// Pruning is optional so compaction-basic remains independently composable.
|
||||
// Overflow always qualifies; pressure first resolves the routed model's
|
||||
// capacity and checks its target-specific threshold.
|
||||
const prune = this.ctx.get('toolResultPruner')
|
||||
|
||||
if (trigger === 'context-overflow') {
|
||||
if (prune !== undefined) {
|
||||
prune.pruneSession(agent.session)
|
||||
measurement = meter.measure(agent.session)
|
||||
}
|
||||
const range = selectCompactableRange(agent.session, measurement, 0)
|
||||
if (range === null) return null
|
||||
return this.compactRegion(range.start, range.end, agent, signal)
|
||||
}
|
||||
|
||||
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(
|
||||
targetKey,
|
||||
`compaction-basic: no context capacity for ${targetKey}; `
|
||||
+ 'configure contextWindow on that adapter model',
|
||||
)
|
||||
}
|
||||
const spec = resolveCompactSpec(policy, context.contextWindow)
|
||||
if (measurement.totalTokens < spec.thresholdTokens) return null
|
||||
|
||||
// Once pressure qualifies, land the model-free pass before choosing a
|
||||
// summary range, then remeasure through the singleton replay fold.
|
||||
if (prune !== undefined) {
|
||||
prune.pruneSession(agent.session)
|
||||
measurement = meter.measure(agent.session)
|
||||
}
|
||||
if (measurement.totalTokens < spec.thresholdTokens) return null
|
||||
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) {
|
||||
const range = selectCompactableRange(agent.session, measurement, spec.retainTokens)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
||||
if (result === null) return null
|
||||
/* v8 ignore next -- paired with the defensive post-success branch above. */
|
||||
break
|
||||
}
|
||||
result = await this.compactRegion(range.start, range.end, agent, signal)
|
||||
measurement = meter.measure(agent.session)
|
||||
if (measurement.totalTokens < spec.thresholdTokens) return result
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts `
|
||||
+ `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact one inclusive positional range from the agent-owned surface using
|
||||
* the effective token meter for all retention and shrink pricing.
|
||||
* @param start - inclusive first surface-node seq.
|
||||
* @param end - inclusive last surface-node seq.
|
||||
* @param agent - owner of the target session, used by the summarizer.
|
||||
* @param signal - optional summarization cancellation signal.
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
override async compactRegion(
|
||||
start: number,
|
||||
end: number,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
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 - cancellation scoped to this compaction request.
|
||||
* @param sourceCommandId - initiating command identity for presentation correlation.
|
||||
* @returns the committed result, or `null` when no safe useful range exists.
|
||||
*/
|
||||
override compactNow(
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
sourceCommandId?: CommandId,
|
||||
): Promise<CompactionResult | null> {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
return agent.runMaintenance(async (agentSignal) => {
|
||||
const operationSignal = AbortSignal.any([agentSignal, signal])
|
||||
try {
|
||||
operationSignal.throwIfAborted()
|
||||
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',
|
||||
...sourceCommandId === undefined ? {} : { sourceCommandId },
|
||||
flush: async () => {
|
||||
await this.ctx.sessions.flush(agent.session)
|
||||
},
|
||||
},
|
||||
operationSignal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (agentSignal.aborted && operationSignal.reason === agentSignal.reason) {
|
||||
throw new ManualCompactionError(
|
||||
'cancelled',
|
||||
'manual compaction was cancelled',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
operationSignal.throwIfAborted()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new ManualCompactionError(
|
||||
'busy',
|
||||
'manual compaction requires an idle agent with no waking queued work',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind the effective token meter and dynamically dispatched summarizer hook. */
|
||||
private regionDependencies(): { meter: TokenMeter; summarize: RegionSummarize } {
|
||||
return {
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (input, owner, abort) => this.summarize(input, owner, abort),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BasicCompactionEngine
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-compaction-basic`.
|
||||
* @module @deepseek-ai/dsh-compaction-basic/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-compaction-basic'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'compaction-basic-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
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 */
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Surface retention selection and the shared log-recorded compaction
|
||||
* transaction for automatic open-turn and manual idle-session compaction.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compaction-basic/region
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
CompactionId,
|
||||
ManualCompactionError,
|
||||
compactCheckpointSource,
|
||||
toolPairingBalancedAfter,
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compaction'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { TokenMeasurement, TokenMeter } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { frameSummary } from './summarizer.ts'
|
||||
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
|
||||
|
||||
interface RegionDependencies {
|
||||
readonly meter: TokenMeter
|
||||
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
|
||||
}
|
||||
|
||||
type SummarizedCompaction = 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>
|
||||
/** Manual command that initiated this transaction, when present. */
|
||||
readonly sourceCommandId?: CommandId
|
||||
}
|
||||
|
||||
interface CompactionEntryState {
|
||||
readonly openTurn: number | null
|
||||
readonly unmatchedCompactionStart: SessionEvent<'compaction/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 `compaction/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.
|
||||
* @param session - session supplying authoritative current surface positions.
|
||||
* @param measurement - unified pressure and surface measurement from the conversation meter.
|
||||
* @param retainTokens - minimum recent tail budget retained verbatim.
|
||||
* @returns the inclusive positional seq range to compact, or `null`.
|
||||
*/
|
||||
export function selectCompactableRange(
|
||||
session: Session,
|
||||
measurement: TokenMeasurement,
|
||||
retainTokens: number,
|
||||
): { start: number; end: number } | null {
|
||||
const pricedNodes = measurement.nodes
|
||||
if (pricedNodes.length === 0) return null
|
||||
|
||||
const surfaceNodes = session.surface.nodes
|
||||
if (surfaceNodes.length !== pricedNodes.length
|
||||
|| surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
|
||||
throw new Error('compaction: token-meter surface does not match the current session surface')
|
||||
}
|
||||
|
||||
let accumulated = 0
|
||||
let keepFromIdx = pricedNodes.length
|
||||
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
accumulated += pricedNodes[index]!.tokens
|
||||
keepFromIdx = index
|
||||
if (accumulated >= retainTokens) break
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
while (keepFromIdx > 0) {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const first = surfaceNodes[0]!
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const cutoff = surfaceNodes[keepFromIdx - 1]!
|
||||
return { start: first, end: cutoff }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the single compaction transaction over one selected positional span.
|
||||
* Selection and validation are read-only. Idle/log validation and
|
||||
* `compaction/start` are synchronously adjacent, so the durable opening marker is
|
||||
* the compaction lock before summarization yields. Every later failure makes
|
||||
* exactly one `compaction/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.
|
||||
*/
|
||||
export async function compactSurfaceRegion(
|
||||
dependencies: RegionDependencies,
|
||||
session: Session,
|
||||
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 compactionId = CompactionId(randomUUID())
|
||||
const lifecycle = {
|
||||
compactionId,
|
||||
...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId },
|
||||
turn: owner,
|
||||
}
|
||||
const startEvent = session.append('compaction/start', lifecycle)
|
||||
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,
|
||||
compactionId,
|
||||
options.sourceCommandId,
|
||||
signal,
|
||||
)
|
||||
if (options.owner === null) signal?.throwIfAborted()
|
||||
assertStable(dependencies, session, summarized)
|
||||
stage = 'commit'
|
||||
const pending = commitCompactionBody(session, startEvent, summarized)
|
||||
closing = true
|
||||
const endEvent = session.append('compaction/end', lifecycle)
|
||||
closed = true
|
||||
result = completeCompaction(pending, endEvent)
|
||||
} catch (error: unknown) {
|
||||
failure = { error, stage: closing ? 'commit' : stage }
|
||||
if (!closing) {
|
||||
closing = true
|
||||
try {
|
||||
session.append('compaction/end', { ...lifecycle, 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<'compaction/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)
|
||||
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
|
||||
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(
|
||||
`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
|
||||
)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
|
||||
}
|
||||
|
||||
/** 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,
|
||||
compactionId: CompactionResult['compactionId'],
|
||||
sourceCommandId: CommandId | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummarizedCompaction> {
|
||||
const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
|
||||
const checkpointMessage = createUserMessage({
|
||||
content: frameSummary(summaryResult.summary),
|
||||
source: compactCheckpointSource(compactionId, sourceCommandId),
|
||||
})
|
||||
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 {
|
||||
current = validateSurfaceRegion(session, prepared.start, prepared.end)
|
||||
} catch (error: unknown) {
|
||||
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 completed summary record and replacement body without yielding. */
|
||||
function commitCompactionBody(
|
||||
session: Session,
|
||||
startEvent: SessionEvent<'compaction/start'>,
|
||||
summarized: SummarizedCompaction,
|
||||
): Omit<CompactionResult, 'endSeq'> {
|
||||
const {
|
||||
start,
|
||||
end,
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
summary,
|
||||
provider,
|
||||
model,
|
||||
maxTokens,
|
||||
usage,
|
||||
checkpointMessage,
|
||||
} = summarized
|
||||
const callProvenance = summarized.llmStreamCall === true
|
||||
? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
|
||||
: summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
|
||||
const summaryEvent = session.append('compaction/summary', {
|
||||
compactionId: startEvent.data.compactionId,
|
||||
...startEvent.data.sourceCommandId === undefined
|
||||
? {}
|
||||
: { sourceCommandId: startEvent.data.sourceCommandId },
|
||||
summary,
|
||||
...callProvenance,
|
||||
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 {
|
||||
compactionId: startEvent.data.compactionId,
|
||||
...startEvent.data.sourceCommandId === undefined
|
||||
? {}
|
||||
: { sourceCommandId: startEvent.data.sourceCommandId },
|
||||
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<'compaction/end'>,
|
||||
): CompactionResult {
|
||||
return { ...pending, endSeq: endEvent.seq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the last routed request's cacheable prefix for the shadowed
|
||||
* region: its system prompt and tool schemas, then the region's own derived
|
||||
* messages in surface order. The summarizer appends only the compaction
|
||||
* instruction after this, so the call is a genuine prefix of the conversation
|
||||
* and reuses the provider's KV cache.
|
||||
* @param session - session supplying the request header and per-node projection.
|
||||
* @param shadowedSeqs - the surface-node seqs, in order, being compacted.
|
||||
* @returns the replayed conversation prefix to condense.
|
||||
*/
|
||||
function buildSummarizationInput(
|
||||
session: Session,
|
||||
shadowedSeqs: readonly number[],
|
||||
): SummarizationInput {
|
||||
const header = session.requestHeader()
|
||||
const events = session.events
|
||||
const regionMessages = shadowedSeqs
|
||||
// shadowedSeqs are current surface seqs, so each is a valid log index.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
.map(seq => session.deriveEventMessage(events[seq]!))
|
||||
.filter((message): message is Message => message !== null)
|
||||
return {
|
||||
...header?.system === undefined ? {} : { system: header.system },
|
||||
...header?.tools === undefined ? {} : { tools: header.tools },
|
||||
messages: regionMessages,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<'compaction/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 (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
|
||||
latestEndSeedSeq = event.seq
|
||||
}
|
||||
if (!compactionEntryStateKnown) {
|
||||
if (event.type === 'compaction/start') {
|
||||
unmatchedCompactionStart = event
|
||||
compactionEntryStateKnown = true
|
||||
} else if (event.type === 'compaction/end') {
|
||||
compactionEntryStateKnown = true
|
||||
}
|
||||
}
|
||||
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 { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Default one-shot summarization and durable checkpoint framing.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compaction-basic/summarizer
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
interface SummaryConfig {
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
}
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/**
|
||||
* The summarization directive, delivered as the FINAL user message after the
|
||||
* replayed conversation rather than as a distinct summarizer system prompt.
|
||||
* Keeping the conversation's own system prompt, tools, and message prefix in
|
||||
* front of it makes the auxiliary call a genuine prefix of the last routed
|
||||
* request, so the provider's KV cache is reused instead of invalidated.
|
||||
*/
|
||||
const COMPACTION_INSTRUCTION = [
|
||||
'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
'## Primary Request and Intent',
|
||||
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
||||
'',
|
||||
'## Key Technical Concepts',
|
||||
'- [technologies, frameworks, patterns, and conventions in play]',
|
||||
'',
|
||||
'## Files and Code',
|
||||
'- [exact path: why it matters, key changes or snippets]',
|
||||
'',
|
||||
'## Errors and Fixes',
|
||||
'- [error: how it was resolved, plus any related user feedback]',
|
||||
'',
|
||||
'## Pending Jobs',
|
||||
'- [explicitly requested work not yet completed]',
|
||||
'',
|
||||
'## Current Work',
|
||||
'- [precisely what was in progress at this checkpoint]',
|
||||
'',
|
||||
'## Next Step',
|
||||
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
||||
'',
|
||||
'## Critical Context',
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- 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.',
|
||||
`- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/** Framing that makes the replacement user message established context. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/**
|
||||
* The replayed conversation surface the summarizer condenses. Reproducing the
|
||||
* last routed request's system prompt, tools, and leading messages verbatim
|
||||
* lets the auxiliary call reuse the provider's warm prefix cache; the trailing
|
||||
* compaction instruction is then the only novel input.
|
||||
*/
|
||||
export interface SummarizationInput {
|
||||
/** The conversation's own system prompt, reused for prefix-cache alignment; absent for a system-less request. */
|
||||
readonly system?: string
|
||||
/** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */
|
||||
readonly tools?: readonly ToolSchema[]
|
||||
/** The shadowed region, in surface order, that precedes the compaction instruction. */
|
||||
readonly messages: readonly Message[]
|
||||
}
|
||||
|
||||
/** Safe summary content plus the exact auxiliary call envelope recorded with it. */
|
||||
export type SummaryResult = {
|
||||
summary: ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
/** Provider-reported usage for this summarization request. */
|
||||
usage?: TokenUsage
|
||||
} & (
|
||||
| {
|
||||
/** Complete provider output before the text-only summary projection. */
|
||||
rawOutput: ContentBlock[]
|
||||
/** Identifies exactly one call through this context's `ctx.llm.stream()`. */
|
||||
llmStreamCall: true
|
||||
}
|
||||
| {
|
||||
/** Optional complete output from an unmarked template, remote, or other summarizer. */
|
||||
rawOutput?: ContentBlock[]
|
||||
/** An unmarked result does not identify a call through this context's LLM seam. */
|
||||
llmStreamCall?: never
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Run the default cache-reusing `ctx.llm.stream()` summarization call: replay
|
||||
* the conversation prefix, then append the compaction instruction as the final
|
||||
* user message so the provider's warm prefix cache is reused.
|
||||
* @param ctx - context providing the LLM service.
|
||||
* @param config - resolved backend configuration.
|
||||
* @param input - replayed conversation prefix (system, tools, and leading messages) to condense.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text-only summary blocks and the exact call envelope and output.
|
||||
*/
|
||||
export async function summarizeWithLlm(
|
||||
ctx: Context,
|
||||
config: SummaryConfig,
|
||||
input: SummarizationInput,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
const latest = agent.session.requestHeader()?.config
|
||||
const configured = config.summarizationProvider.length === 0
|
||||
? undefined
|
||||
: { provider: config.summarizationProvider, model: config.summarizationModel }
|
||||
const agentTarget = agent.options.provider !== undefined
|
||||
&& agent.options.provider.length > 0
|
||||
&& agent.options.model !== undefined
|
||||
&& agent.options.model.length > 0
|
||||
? { provider: agent.options.provider, model: agent.options.model }
|
||||
: undefined
|
||||
const target = configured ?? latest ?? agentTarget
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
'no provider/model available for summarization: set both BasicCompactionConfig summarization fields, route one request, or set both AgentOptions fields',
|
||||
)
|
||||
}
|
||||
|
||||
const assembler = new BlockAssembler()
|
||||
const messages: Message[] = [
|
||||
...input.messages,
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: COMPACTION_INSTRUCTION }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-compaction-basic' },
|
||||
}),
|
||||
]
|
||||
const options: GenerateOptions = {
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
messages,
|
||||
...input.system === undefined ? {} : { system: input.system },
|
||||
...input.tools === undefined ? {} : { tools: [...input.tools] },
|
||||
maxTokens: config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
purpose: 'compaction',
|
||||
...signal === undefined ? {} : { signal },
|
||||
}
|
||||
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
|
||||
const error = finishError(assembler.finish)
|
||||
if (error !== undefined) throw error
|
||||
|
||||
const rawOutput = assembler.blocks()
|
||||
const summary = summaryText(rawOutput)
|
||||
if (!summary.some(block => block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
return {
|
||||
summary,
|
||||
rawOutput,
|
||||
llmStreamCall: true,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
maxTokens: config.maxTokens,
|
||||
...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw summary blocks in the durable checkpoint framing.
|
||||
* @param summary - safe text-only model output.
|
||||
* @returns content for the synthesized replacement user message.
|
||||
*/
|
||||
export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
||||
...summary,
|
||||
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
||||
]
|
||||
}
|
||||
|
||||
/** Map a terminal summarization finish to its fail-closed error. */
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error = new Error(finish.failure.message) as Error & { code?: string }
|
||||
error.code = finish.failure.code
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
|
||||
error.code = 'MAX_TOKENS'
|
||||
return error
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject visual output and keep only text before synthesizing a user message. */
|
||||
function summaryText(
|
||||
blocks: readonly ContentBlock[],
|
||||
): Array<Extract<ContentBlock, { type: 'text' }>> {
|
||||
if (contentHasImage(blocks)) {
|
||||
throw new LlmError('compaction summary cannot contain image output', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Configuration vocabulary for the replay-aware basic compaction backend.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compaction-basic/types
|
||||
*/
|
||||
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Policy fields shared by the default policy and exact model overrides. */
|
||||
export interface CompactionPolicyConfig {
|
||||
/** Compact at this fraction of the model's context window. Defaults to `0.8`. */
|
||||
thresholdRatio?: number
|
||||
/** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */
|
||||
retainRatio?: number
|
||||
/** Absolute recent-context budget; mutually exclusive with `retainRatio`. */
|
||||
retainTokens?: number
|
||||
/** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */
|
||||
summarizationProvider?: string
|
||||
/** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */
|
||||
summarizationModel?: string
|
||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||
maxTokens?: number
|
||||
/** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */
|
||||
compactionRetries?: number
|
||||
/** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */
|
||||
maxOverflowRetries?: number
|
||||
}
|
||||
|
||||
/** Exact provider/model override merged over the default compaction policy. */
|
||||
export interface ModelCompactPolicyConfig extends CompactionPolicyConfig {
|
||||
/** Registered provider route to match. */
|
||||
provider: string
|
||||
/** Exact routed model id to match within `provider`. */
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Basic compaction configuration with an optional exact-target policy table. */
|
||||
export interface BasicCompactionConfig extends CompactionPolicyConfig {
|
||||
/** Exact provider/model overrides; duplicate targets fail plugin load. */
|
||||
modelPolicies?: ModelCompactPolicyConfig[]
|
||||
/** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
/** Exactly one validated retention form. */
|
||||
export type ResolvedRetention =
|
||||
| { readonly retainRatio: number; readonly retainTokens?: never }
|
||||
| { readonly retainRatio?: never; readonly retainTokens: number }
|
||||
|
||||
/** Validated policy fields shared before and after exact-target matching. */
|
||||
interface ResolvedPolicyFields {
|
||||
readonly thresholdRatio: number
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
readonly compactionRetries: number
|
||||
readonly maxOverflowRetries: number
|
||||
}
|
||||
|
||||
/** Validated immutable config whose target-specific defaults remain unresolved. */
|
||||
export type ResolvedConfig = ResolvedPolicyFields & ResolvedRetention & {
|
||||
readonly modelPolicies: readonly Readonly<ModelCompactPolicyConfig>[]
|
||||
readonly auto: boolean
|
||||
}
|
||||
|
||||
/** Fully merged policy for one routed conversation target, before capacity scaling. */
|
||||
export type ResolvedTargetPolicy = ResolvedPolicyFields & ResolvedRetention & {
|
||||
readonly target: Pick<LlmCallConfig, 'provider' | 'model'>
|
||||
}
|
||||
|
||||
/** One routed model's concrete pressure and retention budget. */
|
||||
export type ResolvedCompactSpec = Omit<ResolvedTargetPolicy, 'retainRatio' | 'retainTokens'> & {
|
||||
readonly contextWindow: number
|
||||
readonly thresholdTokens: number
|
||||
readonly retainTokens: number
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
|
||||
import { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import InvariantRegistry 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 { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
|
||||
import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* CBR-001 regression through the real loop. A replacement checkpoint has a high
|
||||
* log seq at the surface head and carries no tool pair, so both adjacent cuts
|
||||
* must be safe and re-compacting that checkpoint alone must succeed. This pins
|
||||
* surface-position semantics rather than raw-log scanning.
|
||||
*/
|
||||
|
||||
class ReproCompactionEngine extends BasicCompactionEngine {
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; provider: string; model: string }> {
|
||||
return {
|
||||
summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }],
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Each call emits one tool-call until exhausted, then a final text answer. */
|
||||
class StepwiseToolAdapter extends LlmAdapter {
|
||||
calls = 0
|
||||
constructor(private toolSteps: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 400 },
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const n = this.calls
|
||||
this.calls += 1
|
||||
if (n < this.toolSteps) {
|
||||
const id = CallId(`c${n}`)
|
||||
const args = `{"i":${n}}`
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: `step ${n}` } }
|
||||
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
|
||||
yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'work', arguments: args } }
|
||||
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||
return
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'all done' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
/** First conversation request overflows, then the rebuilt retry succeeds. */
|
||||
class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
readonly conversationRequests: GenerateOptions[] = []
|
||||
readonly summaryRequests: GenerateOptions[] = []
|
||||
private readonly retryPolicy = resolveRetryPolicy({
|
||||
mode: 'normal',
|
||||
maxRetries: 1,
|
||||
backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
|
||||
}, 'compaction test provider retryPolicy')
|
||||
|
||||
constructor(
|
||||
private readonly delivery: 'thrown' | 'in-band',
|
||||
private readonly transientAfterOverflow = false,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 128 },
|
||||
})
|
||||
}
|
||||
|
||||
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
||||
return this.retryPolicy
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// The cache-reusing summarizer replays the conversation prefix and marks
|
||||
// its call only by the compaction instruction in the trailing user message.
|
||||
const trailing = options.messages.at(-1)?.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('') ?? ''
|
||||
if (trailing.includes('acting as a compaction engine')) {
|
||||
this.summaryRequests.push(options)
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
return
|
||||
}
|
||||
|
||||
this.conversationRequests.push(options)
|
||||
if (this.conversationRequests.length === 1) {
|
||||
if (this.delivery === 'thrown') {
|
||||
throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: {
|
||||
message: 'request too large for model context',
|
||||
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
},
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.transientAfterOverflow && this.conversationRequests.length === 2) {
|
||||
throw new LlmError('temporary provider outage', 'SERVER')
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactionEngine }> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeter)
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'work',
|
||||
description: 'does work',
|
||||
parameters: { i: { type: 'number' } },
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'work result' }]
|
||||
},
|
||||
}))
|
||||
// Small window so several tool steps cross the threshold and compaction
|
||||
// fires within the runaway turn after enough history can shrink.
|
||||
const compact = new ReproCompactionEngine(ctx, {
|
||||
auto: true,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 50,
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
return { ctx, compact }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function overflowHistorySeed(): SessionEvent[] {
|
||||
const session = Session.create(SessionId('overflow-history-seed'))
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
return [...session.events]
|
||||
}
|
||||
|
||||
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
|
||||
it('uses the model actually routed by agent/request for post-step pressure', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
ctx.on('agent/request', async (_payload, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a routed multi-step task' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.requestHeader()?.config.model).toBe('mock')
|
||||
expect(agent.session.events.some(event => event.type === 'compaction/summary')).toBe(true)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs automatic pressure between the completed tool step and the next step', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const compactStart = events.find(event => event.type === 'compaction/start')
|
||||
expect(compactStart).toBeDefined()
|
||||
const precedingResult = events.findLast(event =>
|
||||
event.type === 'tool/result' && event.seq < compactStart!.seq,
|
||||
)
|
||||
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
|
||||
const precedingStepEnd = events.find(event =>
|
||||
event.type === 'step/end'
|
||||
&& event.data.step === precedingResult.data.step
|
||||
&& event.seq > precedingResult.seq,
|
||||
)
|
||||
const nextStepStart = events.find(event =>
|
||||
event.type === 'step/start'
|
||||
&& event.data.step === precedingResult.data.step + 1
|
||||
&& event.seq > compactStart!.seq,
|
||||
)
|
||||
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
// A compaction ran: at least one checkpoint landed on the surface.
|
||||
const checkpoints = events.filter(
|
||||
(e): e is SurfaceEvent =>
|
||||
e.type === 'user/message'
|
||||
&& typeof (e as SurfaceEvent).surfaceOp === 'object',
|
||||
)
|
||||
expect(checkpoints.length).toBeGreaterThan(0)
|
||||
|
||||
// High log position does not make a text-only checkpoint mid-step; both
|
||||
// its start and end cuts are balanced in surface order.
|
||||
const nodes = agent.session.surface.nodes
|
||||
for (const cp of checkpoints) {
|
||||
const index = nodes.indexOf(cp.seq)
|
||||
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(toolPairingBalancedBefore(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
|
||||
expect(toolPairingBalancedAfter(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('context-overflow recovery across the real loop and compaction-basic', () => {
|
||||
it.each(['thrown', 'in-band'] as const)(
|
||||
'force-compacts a %s overflow within the retried step',
|
||||
async (delivery) => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter(delivery)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeter)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_payload, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
await ctx.plugin(BasicCompactionEngine, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
maxTokens: 64,
|
||||
compactionRetries: 0,
|
||||
maxOverflowRetries: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId(`overflow-${delivery}`),
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
},
|
||||
})
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
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')
|
||||
expect(retry).not.toContain('OLD HISTORY SENTINEL')
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const stepStart = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1,
|
||||
)!
|
||||
const stepEnd = events.find(event =>
|
||||
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
|
||||
)!
|
||||
const compaction = events.filter(event =>
|
||||
event.type === 'compaction/start'
|
||||
|| event.type === 'compaction/summary'
|
||||
|| event.type === 'compaction/end',
|
||||
)
|
||||
expect(compaction.map(event => event.type)).toEqual([
|
||||
'compaction/start',
|
||||
'compaction/summary',
|
||||
'compaction/end',
|
||||
])
|
||||
expect(compaction.every(event =>
|
||||
event.seq > stepStart.seq && event.seq < stepEnd.seq,
|
||||
)).toBe(true)
|
||||
expect(events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
|
||||
.toEqual([3])
|
||||
expect(events.filter(event => event.type === 'step/start' && event.data.turn === 3))
|
||||
.toHaveLength(1)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps context-overflow and transient retry budgets independent in one sequence', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter('thrown', true)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(LlmRetry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeter)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
await ctx.plugin(BasicCompactionEngine, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
maxTokens: 64,
|
||||
compactionRetries: 0,
|
||||
maxOverflowRetries: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId('alternating-recovery'),
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(3)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
|
||||
.toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
|
||||
.toEqual([3])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic'
|
||||
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [...lines, ''].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-llm', LlmRuntime],
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-token-meter', TokenMeter],
|
||||
['@deepseek-ai/dsh-compaction-tool-result-pruner', ToolResultPruner],
|
||||
['@deepseek-ai/dsh-compaction-basic', BasicCompactionEngine],
|
||||
])
|
||||
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()
|
||||
return context
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('loads the shipped token-meter, pruning, and compaction-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-compaction-tool-result-pruner'",
|
||||
' config:',
|
||||
' thresholdChars: 100',
|
||||
' headChars: 20',
|
||||
' tailChars: 10',
|
||||
"- name: '@deepseek-ai/dsh-compaction-basic'",
|
||||
' config:',
|
||||
' thresholdRatio: 0.5',
|
||||
' retainRatio: 0.125',
|
||||
' auto: false',
|
||||
])
|
||||
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(loaded.get('toolResultPruner')).toBeInstanceOf(ToolResultPruner)
|
||||
expect(loaded.get('compaction')).toBeInstanceOf(BasicCompactionEngine)
|
||||
expect((loaded.compaction as unknown as BasicCompactionEngine).config).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainRatio: 0.125,
|
||||
auto: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects stale token-meter config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await expect(context.plugin(TokenMeter, {
|
||||
contextWindow: 4096,
|
||||
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/)
|
||||
})
|
||||
|
||||
it('rejects stale compaction-basic config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmRuntime)
|
||||
await context.plugin(SessionStore)
|
||||
await context.plugin(TokenMeter)
|
||||
await expect(context.plugin(BasicCompactionEngine, {
|
||||
models: { legacy: { thresholdRatio: 0.5 } },
|
||||
} as never)).rejects.toThrow(/BasicCompactionConfig: unknown key "models"/)
|
||||
})
|
||||
|
||||
it('rejects a capacity-independent merged ratio conflict during plugin load', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmRuntime)
|
||||
await context.plugin(SessionStore)
|
||||
await context.plugin(TokenMeter)
|
||||
await expect(context.plugin(BasicCompactionEngine, {
|
||||
retainRatio: 0.2,
|
||||
modelPolicies: [{
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
thresholdRatio: 0.1,
|
||||
}],
|
||||
})).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/)
|
||||
})
|
||||
|
||||
it('rejects an incomplete model-policy summarization pair during plugin load', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmRuntime)
|
||||
await context.plugin(SessionStore)
|
||||
await context.plugin(TokenMeter)
|
||||
await expect(context.plugin(BasicCompactionEngine, {
|
||||
summarizationProvider: 'default-provider',
|
||||
summarizationModel: 'default-model',
|
||||
modelPolicies: [{
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
summarizationModel: '',
|
||||
}],
|
||||
})).rejects.toThrow(/modelPolicies\[0\].*must be set together/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,885 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import * as CompactionInvariant from '@deepseek-ai/dsh-compaction/invariant'
|
||||
import * as CompactionBasicInvariant from '@deepseek-ai/dsh-compaction-basic/invariant'
|
||||
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
|
||||
import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compaction'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
|
||||
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, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
SummarizationInput,
|
||||
SummaryResult,
|
||||
} from '@deepseek-ai/dsh-compaction-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 GatedCompactionEngine extends BasicCompactionEngine {
|
||||
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: GatedCompactionEngine
|
||||
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(InvariantRegistry)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
await ctx.plugin(CompactionInvariant)
|
||||
await ctx.plugin(CompactionBasicInvariant)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeter)
|
||||
const adapter = new TextAdapter()
|
||||
ctx.llm.registerAdapter([MODEL], adapter)
|
||||
const compact = new GatedCompactionEngine(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')
|
||||
if (event.type === 'turn/end') log.push('turn/end')
|
||||
if (event.type === 'compaction/start') log.push(`compaction/start:${String(event.data.turn)}`)
|
||||
if (event.type === 'compaction/summary') log.push('compaction/summary')
|
||||
if (event.type === 'compaction/end') log.push(`compaction/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<unknown>)): Promise<ManualCompactionError> {
|
||||
let caught: unknown
|
||||
try {
|
||||
const value = await (typeof operation === 'function' ? operation() : operation)
|
||||
throw new Error(`expected a rejection, resolved with ${String(value)}`)
|
||||
} catch (error: unknown) {
|
||||
caught = 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 = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`))
|
||||
for (let index = 1; index <= turns; index += 1) {
|
||||
const turn = index === turns ? lastTurnNumber : index
|
||||
session.append('turn/start', { turn })
|
||||
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 maintenance claim is scripted per test. */
|
||||
function fakeAgent(
|
||||
session: Session,
|
||||
reserve: () => (() => void) | undefined,
|
||||
maintenanceSignal = new AbortController().signal,
|
||||
): Agent {
|
||||
return {
|
||||
session,
|
||||
options: { provider: MODEL, model: MODEL },
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
const release = reserve()
|
||||
if (release === undefined) throw new Error('agent already has active work')
|
||||
return task(maintenanceSignal).finally(release)
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
/** Service over a store-detached session for failure classification. */
|
||||
function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flushes: () => number } {
|
||||
const ctx = new Context()
|
||||
void new LlmRuntime(ctx)
|
||||
void new SessionStore(ctx)
|
||||
void new TokenMeter(ctx)
|
||||
ctx.llm.registerAdapter([MODEL], new TextAdapter())
|
||||
let flushes = 0
|
||||
vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
|
||||
flushes += 1
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
return { ctx, compact: new GatedCompactionEngine(ctx, { auto: false }), flushes: () => flushes }
|
||||
}
|
||||
|
||||
function compactEvents(session: Session): Array<Session['events'][number]> {
|
||||
return session.events.filter(event => event.type.startsWith('compaction/'))
|
||||
}
|
||||
|
||||
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(['compaction/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(['compaction/start:null'])
|
||||
|
||||
gate.resolve()
|
||||
const result = await running
|
||||
expect(result).not.toBeNull()
|
||||
await agent.whenIdle()
|
||||
|
||||
const start = log.indexOf('compaction/start:null')
|
||||
const summary = log.indexOf('compaction/summary')
|
||||
const end = log.indexOf('compaction/end:null')
|
||||
const flush = log.indexOf('flush')
|
||||
const nextTurn = log.indexOf('turn/start')
|
||||
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 pending for the next step', 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 === 'compaction/start')
|
||||
const injected = agent.inbox.nextStep.find(message =>
|
||||
message.source.kind === 'plugin' && message.source.plugin === 'test')
|
||||
const end = agent.session.events.findLast(event => event.type === 'compaction/end')
|
||||
expect(start).toBeDefined()
|
||||
expect(injected).toBeDefined()
|
||||
expect(end).toBeDefined()
|
||||
expect(agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.id === injected?.id)).toBe(false)
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'after compaction' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
const messages = derivedText(agent.session)
|
||||
expect(messages[0]).toContain('checkpoint')
|
||||
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 !== 'compaction/start' && event.type !== 'compaction/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(['compaction/start', 'compaction/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(['compaction/start', 'compaction/summary', 'compaction/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 === 'compaction/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(['compaction/start', 'compaction/end'])
|
||||
expect(markers[1]?.type === 'compaction/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 = Session.create(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 commandId = CommandId('manual-compact-command')
|
||||
|
||||
const result = await compact.compactNow(agent, SIGNAL, commandId)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.sourceCommandId).toBe(commandId)
|
||||
expect(flushes()).toBe(1)
|
||||
expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
|
||||
const start = session.events.findLast(event => event.type === 'compaction/start')
|
||||
const summaryEvent = session.events.findLast(event => event.type === 'compaction/summary')
|
||||
const checkpoint = session.events.findLast(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source),
|
||||
)
|
||||
const end = session.events.findLast(event => event.type === 'compaction/end')
|
||||
const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId }
|
||||
expect(start?.data).toEqual({ ...correlated, turn: null })
|
||||
expect(summaryEvent?.data.sourceCommandId).toBe(commandId)
|
||||
expect(checkpoint?.data.source).toMatchObject(correlated)
|
||||
expect(end?.data).toEqual({ ...correlated, turn: null })
|
||||
})
|
||||
|
||||
it('reports a live unmatched bracket as busy without summarizing', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
session.append('compaction/start', {
|
||||
compactionId: CompactionId('live-manual-compaction'),
|
||||
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('compaction/start', {
|
||||
compactionId: CompactionId('stale-manual-compaction'),
|
||||
turn: null,
|
||||
})
|
||||
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
|
||||
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
|
||||
const orphan = reloaded.events.find(event => event.type === 'compaction/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('compaction/start', {
|
||||
compactionId: CompactionId('reloaded-manual-compaction'),
|
||||
turn: null,
|
||||
})
|
||||
original.append('turn/start', { turn: 3 })
|
||||
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
|
||||
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
|
||||
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 })
|
||||
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(['compaction/start', 'compaction/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(['compaction/start', 'compaction/end'])
|
||||
expect(session.events.some(event => event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a failing compaction/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 === 'compaction/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('compaction/'))?.type)
|
||||
.toBe('compaction/summary')
|
||||
expect(compactEvents(session).filter(event => event.type === 'compaction/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 === 'compaction/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(['compaction/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 === 'compaction/summary') throw new Error('summary record 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 === 'compaction/end')
|
||||
expect(end?.type === 'compaction/end' && end.data.error).toContain('summary record rejected')
|
||||
expect(end?.type === 'compaction/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 === 'compaction/summary') throw new Error('summary record rejected')
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
expect(error.code).toBe('commit')
|
||||
expect(causeOf(error).message).toBe('summary record rejected')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('compacts a session with no durable turn boundary without creating one', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = Session.create(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 === 'compaction/start')?.data)
|
||||
.toEqual({ compactionId: result?.compactionId, 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 === 'compaction/summary')).toBe(true)
|
||||
const start = session.events.findLast(event => event.type === 'compaction/start')
|
||||
const end = session.events.findLast(event => event.type === 'compaction/end')
|
||||
expect(end?.data).toEqual({ compactionId: start?.data.compactionId, 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: Session.create(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)
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
void compact.compactNow(agent, controller.signal)
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).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(['compaction/start', 'compaction/end'])
|
||||
expect(events[1]?.type === 'compaction/end' && events[1].data.error)
|
||||
.toContain('summarizer aborted')
|
||||
})
|
||||
|
||||
it('classifies agent cancellation during maintenance as an expected cancellation', async () => {
|
||||
const { compact } = detachedService()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('agent cancelled maintenance')
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined, controller.signal)
|
||||
compact.duringSummary = () => { controller.abort(reason) }
|
||||
compact.error = new Error('summarizer observed cancellation')
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
|
||||
expect(error.code).toBe('cancelled')
|
||||
expect(error.cause).toBe(reason)
|
||||
})
|
||||
|
||||
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(['compaction/start', 'compaction/end'])
|
||||
expect(session.events.some(event => event.type === 'compaction/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<boolean>()
|
||||
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(false)
|
||||
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 === 'compaction/summary')
|
||||
expect(summary?.type === 'compaction/summary' && summary.data.rawOutput).toEqual(compact.rawOutput)
|
||||
expect(summary?.type === 'compaction/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 === 'compaction/start')
|
||||
const end = session.events.findLast(event => event.type === 'compaction/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 })
|
||||
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) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../compaction"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../compaction-tool-result-pruner"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user