fix(compaction): address model-switch review details

This commit is contained in:
Dudu-0223 committed 2026-10-16 14:30:58 +08:00
1 parent f9761a140c
commit aa68564ff4
12 files changed
+208 -27

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
en: 9dde333b53c14f24
zh: 8959aafefc55f9a4
/compaction/the-service:
en: 3b7544ff9cc0f0db
zh: e9a5e4c6c412eab0
en: 6c613669a0a61c16
zh: e9f23445bc0a5537
/compaction/tool-result-pruning-outcomes:
en: 27091b0a3d454575
zh: e22431e27e1349f2
+1 -1
View File
@@ -98,7 +98,7 @@ type ManualCompactionErrorCode =
`changed` and `summary` close and persist the failed attempt without a summary replacement; image omissions recorded during recovery remain effective. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup.
Pressure compaction runs at the `agent/pre-step` waterfall before request derivation. Once pressure or canonical overflow qualifies, compaction-basic invokes optional [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compaction-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
Pressure compaction runs at `agent/pre-step` against the committed request header and at `agent/request` against a changed destination before its header is logged. Model-switch summaries retain the previous route unless an explicit summary target is configured; a failed switch preserves that header and any completed reductions. Once pressure or canonical overflow qualifies, compaction-basic invokes optional [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compaction-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for the tool-call/result pairing checks before and after a seq. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compaction/compaction/README.md#tool-pairing-boundaries) defines their cache behavior.
+1 -1
View File
@@ -98,7 +98,7 @@ type ManualCompactionErrorCode =
`changed` 和 `summary` 闭合失败尝试并将其持久化到日志,不写入摘要替换;恢复过程中记录的图片省略仍然有效。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。
压力压缩在 `agent/pre-step` waterfall(瀑布式事件)中运行,先于请求推导。一旦压力或规范化溢出满足条件,compaction-basic 会在选择范围前调用可选的 [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.zh.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compaction-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。
压力压缩在 `agent/pre-step` 按已提交请求 header 执行,也在 `agent/request` 按已改变的目标执行,先于其 header 记入日志。除非配置了显式摘要目标,模型切换摘要仍使用先前路由;切换失败会保留该 header 与已完成的缩减。一旦压力或规范化溢出满足条件,compaction-basic 会在选择范围前调用可选的 [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.zh.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compaction-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。
该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于检查 seq 之前与之后的工具调用/结果配对。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;[包约定](../../packages/compaction/compaction/README.zh.md#tool-pairing-boundaries)定义其缓存行为。
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/model-switch-compaction/guide.md
/:
en: 830f003296555f6f
zh: a83508af05b217ff
/model-switches-check-destination-context-pressure:
en: 7d11a333984b08b7
zh: 93f0d5f2c309253d
/model-switches-check-destination-context-pressure/change:
en: 613c81a294809de3
zh: 6a8747f2f326c390
/model-switches-check-destination-context-pressure/migration:
en: c17dc3c452145b02
zh: 9a905067e047e275
@@ -0,0 +1,30 @@
---
kind: upgrade-guide
description: "Automatic model switches require a valid destination pressure budget and successful compaction."
---
# Model switches check destination context pressure
English | [中文](guide.zh.md)
## Change
With `compaction-basic` automatic mode enabled, a provider/model switch now checks destination pressure before recording the new request header. Previously, proactive pressure checks used the last committed route and warned on configuration or summary failure. A switch now fails with `MODEL_SWITCH_COMPACTION_FAILED` when destination capacity cannot be resolved, its pressure budget is invalid, or compaction cannot reduce history below its threshold. This also affects short conversations with invalid destination budgets. Completed pruning and earlier checkpoints remain; the previous request header is preserved.
## Migration
1. Ensure every selectable route has a registered adapter whose `resolveModelInfo()` declares `context.contextWindow`. For middleware-owned routes without this metadata, expose the capacity through an adapter or use `auto: false` for manual-only compaction; this also disables automatic pressure and overflow recovery.
2. For small windows, configure `headroomTokens` in `cordis.yml` under the exact provider/model policy. The default is 65,536 tokens and must fit alongside the routed request's output reservation. For a 65,536-token window with a 4,096-token request output cap, this policy leaves a positive pressure budget:
```yaml
- name: '@deepseek-ai/dsh-compaction-basic'
config:
modelPolicies:
- provider: local
model: small-context
headroomTokens: 4096
maxTokens: 4096
```
3. Replace `local` and `small-context` with the registered route. The policy's `maxTokens` caps summary output; the destination request's own output cap is separate. Keep the resolved retained budget below the trigger. Switch once with short history to verify configuration, then with long history to verify condensation. If the switch fails, correct the reported cause and retry, or choose a larger model. See the [package configuration reference](../../../../packages/compaction/compaction-basic/README.md#use-this-package).
@@ -0,0 +1,30 @@
---
kind: upgrade-guide
description: "自动模型切换要求有效的目标压力预算与成功的压缩。"
---
# 模型切换检查目标上下文压力
[English](guide.md) | 中文
## 变更
启用 `compaction-basic` 自动模式时,提供方/模型切换会在记录新请求 header 前检查目标压力。此前,主动压力检查使用上次已提交路由,并对配置或摘要失败记录警告。现在,无法解析目标容量、压力预算无效或压缩无法将历史降至阈值以下时,切换会以 `MODEL_SWITCH_COMPACTION_FAILED` 失败。这也影响目标预算无效的短会话。已完成的剪枝与之前的检查点仍保留;先前请求 header 也保持不变。
## 迁移
1. 确保每个可选路由都有已注册的适配器,且其 `resolveModelInfo()` 声明了 `context.contextWindow`。对于缺少此元数据的中间件路由,通过适配器暴露容量,或设置 `auto: false` 仅使用手动压缩;这也会关闭自动压力压缩与溢出恢复。
2. 对于小窗口,在 `cordis.yml` 的精确提供方/模型策略下配置 `headroomTokens`。默认值为 65,536 token,必须与路由请求的输出预留共同适配窗口。对于 65,536 token 窗口与 4,096 token 的请求输出上限,下列策略会留下正数压力预算:
```yaml
- name: '@deepseek-ai/dsh-compaction-basic'
config:
modelPolicies:
- provider: local
model: small-context
headroomTokens: 4096
maxTokens: 4096
```
3. 将 `local` 与 `small-context` 替换为已注册路由。策略的 `maxTokens` 限制摘要输出;目标请求自身的输出上限单独设置。解析后的保留预算必须低于触发阈值。先用短历史切换一次以验证配置,再用长历史验证压缩。切换失败时,修正所报原因后重试,或选择更大的模型。详见[包配置参考](../../../../packages/compaction/compaction-basic/README.zh.md#use-this-package)。
@@ -30,8 +30,8 @@
en: c872a8ef069596f0
zh: b3a48e309ae830d4
/deepseek-ai-dsh-compaction-basic/use-this-package/switching-models:
en: 220462fa3d9af674
zh: b3fcc9832d5f42cb
en: 48978abc46da1afa
zh: 11931af05db7b41f
/deepseek-ai-dsh-compaction-basic/use-this-package/on-demand-condensation-with-compact:
en: 4c5067e4c57ca708
zh: 1c0e70319b9b2112
@@ -42,11 +42,11 @@
en: deb655b3b1860576
zh: 79f56e41a9dfe01f
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/design-philosophy:
en: 54945a18965f12a8
zh: fecd5d4fc2f2e6d6
en: ed2d43610d4eaa83
zh: b46a1eaad396cdd7
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/automatic-triggers-and-overflow-recovery:
en: d62d65547dd0b2dc
zh: 5c21671f16d94f5a
en: 9e6e472b0f3977d3
zh: fdc0e827c19ba401
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/summarization-mechanics:
en: 967531b71453eb88
zh: c7919b46e36763e6
@@ -96,8 +96,8 @@
en: d31d3e3d46542c91
zh: 5a42f873993e0c79
/deepseek-ai-dsh-compaction-basic/known-limitations-and-deferred-work:
en: 8f459fa26e079883
zh: 4acdcefa71ebb409
en: e2514d868dd5abbb
zh: a84fd6428d5c245a
/deepseek-ai-dsh-compaction-basic/known-limitations-and-deferred-work/dev-note:
en: 1fec7b90c3b68c49
zh: ad9ae54a598846d0
+10 -3
View File
@@ -54,6 +54,8 @@ You can verify success by watching the conversation continue past the point wher
- provider: local
model: small-context
thresholdRatio: 0.7
headroomTokens: 4096
maxTokens: 4096
retainTokens: 2048
```
@@ -85,6 +87,8 @@ The oldest balanced span is replaced by one summary message and the recent tail
Before a changed provider/model request is committed, automatic compaction measures the existing history against the destination model's capacity, output reservation, and pressure policy. When condensation is needed, it uses the previous routed model and its summary settings; an explicitly configured summary target still takes precedence. The destination request proceeds only after history falls below its threshold. Failure reports `MODEL_SWITCH_COMPACTION_FAILED` and leaves the previous request header intact; select a larger model or retry after correcting the reported cause. Completed pruning or condensation remains in the session even when a later attempt fails. The check runs when the next request is prepared, and `auto: false` disables it.
The destination must have a registered adapter, declared context capacity, and a valid pressure budget even for short history. The default 65,536-token headroom does not fit a window of that size or smaller; set `modelPolicies[].headroomTokens` for that route as in the example above. See the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.4/model-switch-compaction/guide.md) for migration.
### On-demand condensation with /compact
With `dsh-command-compact` mounted, type `/compact` in a chat UI to condense immediately, even below the pressure threshold. The command reports how many history items were condensed and the estimated tokens saved. While the agent is mid-turn or condensation is already running, `/compact` reports that condensation is unavailable; prompts you send while it runs are accepted and start after it finishes.
@@ -107,16 +111,18 @@ This section explains the design decisions behind the backend; the observable be
The backend is built on four commitments:
- **One measurement service prices every decision.** The singleton `ctx.tokenMeter` measures the latest canonical logged envelope and current surface at one consumed-log revision. When the routed adapter declares request-image pricing, the meter applies it to image history. Pressure, recent-tail retention, range selection, and shrink validation use the same route-priced node figures; logged replacement shadow prices stay on the route-independent heuristic so pure projection folds remain consistent.
- **One measurement service prices every decision.** The singleton `ctx.tokenMeter` measures the latest canonical logged envelope and current surface at one consumed-log revision. When the routed adapter declares request-image pricing, the meter applies it to image history. Model-switch pressure, retention, and range selection use destination pricing; region shrink validation uses the last committed route; logged replacement shadow prices stay on the route-independent heuristic so pure projection folds remain consistent.
- **The log-recorded bracket is the transaction.** All entry points share one bracket-first region transaction: validate the range and live lock, append `compaction/start` synchronously, prepare and await the summary, revalidate, append `compaction/summary` plus the replacement, and make exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
- **Summarization reuses the provider's warm prefix.** Replaying the system prompt held by the `system/message` at surface node 0, the last routed request's tools, and the shadowed-region messages byte-for-byte makes the auxiliary call a genuine prefix of the conversation, so only the trailing instruction and the summary output are uncached.
- **`summarize()` 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 the token meter.
### Automatic triggers and overflow recovery
The `agent/request` listener waits for routing listeners to select the destination, then checks its pressure before the new header is logged. It uses `compactPressure` directly because `compactIfNeeded` reads the committed header; both paths generate summaries through `summarize()`.
With `auto: true`, a serial `agent/pre-step` listener checks pressure before request derivation: it prices the latest durable routed request envelope through `ctx.tokenMeter`, and when pressure crosses the routed model's threshold it prunes, then summarizes the oldest balanced span while keeping a priced recent tail. Every selected range starts at the first surface node that is not a `system/message`, so a system prompt at surface node 0 is never shadowed; a later `system/message` appended by an in-history prompt update is ordinary history that the range may shadow, and the agent loop's projection then replaces node 0 with the current prompt when their text differs ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The `agent/request-error` listener reacts to a provider-confirmed `CONTEXT_WINDOW_EXCEEDED`: it bypasses the normal threshold and retention policy, attempts one maximal balanced head reduction, and authorizes a retry only after the surface replacement generation advances. Cancellation stays authoritative throughout.
Pressure policy resolves capacity from the adapter that owns the durable route. Missing capacity, output plus headroom exhausting the window, or a retained budget at least as large as the threshold makes the manual pressure path throw a target-specific configuration error. The automatic listener warns once for that exact target and skips proactive compaction until its configuration is corrected; provider-confirmed overflow recovery remains available.
Pressure policy resolves capacity from the adapter that owns the durable route. Missing capacity, output plus headroom exhausting the window, or a retained budget at least as large as the threshold makes the manual pressure path throw a target-specific configuration error. The `agent/pre-step` listener warns once for that exact target and continues the turn; provider-confirmed overflow recovery remains available. Model-switch checks instead reject the request with `MODEL_SWITCH_COMPACTION_FAILED`, including when capacity or adapter lookup fails.
### Summarization mechanics
@@ -247,7 +253,8 @@ These limits define when automatic condensation is a poor fit or needs special c
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize 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.
- **Summarization failure preserves the latest durable surface** — pre-step pressure logs a warning and continues; model-switch pressure rejects the destination request. Completed pruning and earlier checkpoints survive either failure. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
- **Cross-model image pricing can reject a useful summary** — switch range selection uses destination pricing, while shrink validation uses the committed route. Different image prices can make a summary fail shrink validation even when it would reduce destination pressure.
<a id="dev-note"></a>
### Dev Note
@@ -54,6 +54,8 @@ kind: "package-reference"
- provider: local
model: small-context
thresholdRatio: 0.7
headroomTokens: 4096
maxTokens: 4096
retainTokens: 2048
```
@@ -85,6 +87,8 @@ kind: "package-reference"
在提供方或模型发生变化的请求写入日志前,自动压缩会按目标模型的容量、输出预留和压力策略测量现有历史。需要压缩时,使用原路由模型及其摘要设置;显式配置的摘要目标仍然优先。只有历史降到目标阈值以下,才会继续目标请求。失败会报告 `MODEL_SWITCH_COMPACTION_FAILED`,并保留原请求头;可选择更大窗口的模型,或修正报告的原因后重试。即使后续尝试失败,已经完成的裁剪或压缩仍保留在会话中。检查在下次请求准备时执行,`auto: false` 会禁用它。
即使历史很短,目标也必须有已注册的适配器、已声明的上下文容量和有效的压力预算。默认 65,536 token 的余量无法适配同等或更小的窗口;请按上例为该路由设置 `modelPolicies[].headroomTokens`。迁移方法见[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.4/model-switch-compaction/guide.zh.md)。
### 通过 /compact 按需压缩
挂载 `dsh-command-compact` 后,在聊天 UI 中输入 `/compact` 即可立即压缩,即使未达到压力阈值。命令会报告压缩了多少历史项以及估算节省的 token 数。当 agent 正在轮次中或压缩已在运行时,`/compact` 会报告压缩暂不可用;运行期间你发送的提示词会被接受,并在压缩结束后才开始。
@@ -107,16 +111,18 @@ kind: "package-reference"
该后端建立在四项承诺之上:
- **一个测量服务为每个决策定价。** 单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上测量最新规范已记录 envelope 与当前表层。路由适配器声明请求图片定价时,meter 会将其应用于图片历史。压力、近期尾部保留、范围选择与缩减验证使用同一套路由定价的节点数值;已记录的替换影子价仍使用与路由无关的启发式规则,使纯投影 fold 保持一致。
- **一个测量服务为每个决策定价。** 单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上测量最新规范已记录 envelope 与当前表层。路由适配器声明请求图片定价时,meter 会将其应用于图片历史。模型切换的压力、保留与范围选择使用目标定价;区域缩减验证使用上次已提交路由;已记录的替换影子价仍使用与路由无关的启发式规则,使纯投影 fold 保持一致。
- **日志记录的标记对就是事务。** 所有入口点共享一个先记录标记的区域事务:验证范围与活动锁,同步追加 `compaction/start`,准备并等待摘要,重新验证,再追加 `compaction/summary` 与替换,最后恰好进行一次闭合尝试。自动调用与显式范围调用要求数字标识的开放轮次归属与整个表层稳定;`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
- **摘要复用提供方的热前缀。** 逐字回放 surface 节点 0 处 `system/message` 所承载的系统提示词、上次已路由请求的工具与已遮蔽区域消息,使辅助调用成为会话的真正前缀,因此只有尾随指令与摘要输出未缓存。
- **`summarize()` 是唯一的子类钩子。** 基于模板或远程摘要器的子类可以覆盖它,同时压力、保留、被引用的源事件、缩减验证与已遮蔽 token 计量仍由 token meter 负责。
### 自动触发与溢出恢复
`agent/request` listener 等待路由 listener 选定目标,再在新 header 记入日志前检查其压力。它直接使用 `compactPressure`,因为 `compactIfNeeded` 读取的是已提交的 header;两条路径都通过 `summarize()` 生成摘要。
当 `auto: true` 时,串行 `agent/pre-step` listener 会在请求派生前检查压力:它通过 `ctx.tokenMeter` 为最新持久路由请求 envelope 定价,当压力越过路由模型的阈值时,先剪枝,再在保留已定价近期尾部的同时摘要最旧的平衡范围。每个选定范围都从第一个不是 `system/message` 的 surface 节点开始,因此位于 surface 节点 0 的系统提示词永不会被遮蔽;由历史内提示词更新追加的后续 `system/message` 是普通历史,范围可以遮蔽它,agent loop(智能体循环)的投影随后会在二者文本不同时用当前提示词替换节点 0([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。`agent/request-error` listener 响应提供方确认的 `CONTEXT_WINDOW_EXCEEDED`:它绕过常规阈值与保留策略,尝试一次最大平衡头部缩减,并且只在表层替换 generation 前进后才授权重试。取消全程保持最终决定权。
压力策略从拥有持久路由的适配器解析容量。容量缺失、输出预留与余量耗尽窗口,或保留预算不小于阈值时,手动压力路径会抛出目标特定配置错误。自动 listener 会对该精确目标警告一次,并在配置修正前跳过主动压缩;提供方确认溢出后的恢复仍然可用。
压力策略从拥有持久路由的适配器解析容量。容量缺失、输出预留与余量耗尽窗口,或保留预算不小于阈值时,手动压力路径会抛出目标特定配置错误。`agent/pre-step` listener 会对该精确目标警告一次,并继续本轮;提供方确认溢出后的恢复仍然可用。模型切换检查则以 `MODEL_SWITCH_COMPACTION_FAILED` 拒绝请求,容量或适配器查找失败时亦如此。
### 摘要机制
@@ -247,7 +253,8 @@ Rules:
- **溢出分类由适配器维护**——提供方措辞可能改变;两个 DeepSeek 适配器将可识别的上下文限制失败规范化为 `CONTEXT_WINDOW_EXCEEDED`。
- **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**——恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。
- **`compactRegion` 要求存在未结束的轮次**——在完全关闭的会话上手动调用会抛出异常(「no open turn」),而不是执行压缩。
- **摘要失败会保留最新持久表层**——任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。因达到 `maxTokens` 而发生的摘要截断(隐藏推理 token 可能会耗尽该额度)遵循同一规则。
- **摘要失败会保留最新持久表层**——步前压力检查会记录警告并继续;模型切换压力检查会拒绝目标请求。两种失败都会保留已完成的剪枝与之前的检查点。因达到 `maxTokens` 而发生的摘要截断(隐藏推理 token 可能会耗尽该额度)遵循同一规则。
- **跨模型图片定价可能拒绝有效摘要**——切换时按目标定价选择范围,但按已提交路由验证缩减。不同的图片价格可能使摘要未通过缩减验证,即使它能降低目标压力。
<a id="dev-note"></a>
### 开发备注
@@ -140,9 +140,9 @@ export class BasicCompactionEngine extends CompactionEngine {
}
/**
* Register automatic between-step pressure and model-request overflow
* recovery. `compactIfNeeded` stays dynamically dispatched so subclass
* overrides are honored at event time.
* Register between-step pressure, model-switch pressure, and request-overflow
* recovery. Pre-step and overflow listeners dispatch through `compactIfNeeded`;
* model switches price the proposed header directly. All summaries use `summarize`.
*/
private _registerAutomaticCompaction(): void {
const { ctx } = this
@@ -178,10 +178,12 @@ export class BasicCompactionEngine extends CompactionEngine {
if (status === 'idle') this.overflowRetries.delete(agent)
})
// Prepend wraps routing listeners: await their final selection while the
// committed header still identifies the model that can summarize the history.
ctx.on('agent/request', async ({ agent, signal }, next) => {
const config = await next()
const previous = agent.session.requestHeader()
if (signal.aborted || previous === undefined
if (signal.aborted || previous === undefined || config.provider === '' || config.model === ''
|| (previous.config.provider === config.provider && previous.config.model === config.model)) return config
try {
const header = { ...previous, config }
@@ -310,12 +312,12 @@ export class BasicCompactionEngine extends CompactionEngine {
}
}
/** Price a proposed envelope while summaries retain the last committed route. */
/** Price the proposed header; switch checks require history below its threshold. */
private async compactPressure(
agent: Agent,
header: EpochHeader,
signal: AbortSignal,
requireFit: boolean,
requireBelowThreshold: boolean,
): Promise<CompactionResult | null> {
const target = header.config
const policy = resolveTargetPolicy(this.config, target)
@@ -351,7 +353,7 @@ export class BasicCompactionEngine extends CompactionEngine {
for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) {
const range = selectCompactableRange(agent.session, measurement, spec.retainTokens)
if (range === null) {
if (result === null && !requireFit) return null
if (result === null && !requireBelowThreshold) return null
throw new Error(
'no safe compaction range can reduce context below threshold '
+ `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`,
@@ -1727,6 +1727,92 @@ describe('model-switch pressure', () => {
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(300)
})
it.each([false, true])('requires a valid small-window policy even for short history (override: %s)', async (override) => {
const { ctx, adapter } = harness({
headroomTokens: 65_536, maxTokens: 65_536,
modelPolicies: override ? [{ provider: '64k', model: '64k', headroomTokens: 4_096, maxTokens: 4_096 }] : [],
})
ctx.llm.registerAdapter(['64k'], new ContextAdapter(65_536))
const session = conversation(1, 'short')
const previous = session.requestHeader()
const selected = { provider: '64k', model: '64k', maxTokens: 4_096 }
const switching = request(ctx, session, selected)
if (override) await expect(switching).resolves.toEqual(selected)
else await expect(switching).rejects.toMatchObject({ code: 'MODEL_SWITCH_COMPACTION_FAILED' })
expect(session.requestHeader()).toEqual(previous)
expect(adapter.lastOptions).toBeUndefined()
})
it('repeatedly summarizes on the previous route until destination pressure is below threshold', async () => {
const { ctx, adapter } = harness()
const first = new ScriptedAdapter([{ type: 'text', text: 'm'.repeat(1_600) }])
const calls = vi.spyOn(adapter, 'stream').mockImplementationOnce(options => first.stream(options))
const session = conversation(4, 'fixture '.repeat(80))
const previous = session.requestHeader()
const selected = { provider: 'small', model: 'small' }
await expect(request(ctx, session, selected)).resolves.toEqual(selected)
expect(calls).toHaveBeenCalledTimes(2)
for (const [options] of calls.mock.calls) expect(options).toMatchObject({ provider: MODEL, model: MODEL })
expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(2)
expect(ctx.tokenMeter.measure(session, { config: selected }).totalTokens).toBeLessThan(500)
expect(session.requestHeader()).toEqual(previous)
})
it.each(['summary error', 'non-shrinking summary', 'cancellation'] as const)(
'keeps the first checkpoint and permits retry after a second-attempt %s',
async (failure) => {
const { ctx, adapter } = harness()
const first = new ScriptedAdapter([{ type: 'text', text: 'm'.repeat(1_600) }])
const second = failure === 'non-shrinking summary' ? first : new ScriptedAdapter([], {
kind: 'error', failure: { code: 'AUTH', message: 'summary credentials expired' },
})
const controller = new AbortController()
const cancelled = new Error('cancel second summary')
vi.spyOn(adapter, 'stream')
.mockImplementationOnce(options => first.stream(options))
.mockImplementationOnce((options) => {
if (failure === 'cancellation') controller.abort(cancelled)
return second.stream(options)
})
const session = conversation(4, 'fixture '.repeat(80))
const previous = session.requestHeader()
const selected = { provider: 'small', model: 'small' }
const switching = request(ctx, session, selected, controller.signal)
if (failure === 'cancellation') await expect(switching).rejects.toBe(cancelled)
else await expect(switching).rejects.toMatchObject({ code: 'MODEL_SWITCH_COMPACTION_FAILED' })
expect(session.requestHeader()).toEqual(previous)
expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(1)
expect(session.snapshotEvents().filter(event => event.type === 'compaction/start')).toHaveLength(2)
expect(session.snapshotEvents().filter(event => event.type === 'compaction/end')).toHaveLength(2)
expect(session.deriveMessages().some(message => message.source.kind === 'compact-checkpoint')).toBe(true)
await expect(request(ctx, session, selected)).resolves.toEqual(selected)
expect(ctx.tokenMeter.measure(session, { config: selected }).totalTokens).toBeLessThan(500)
},
)
it('stops after the destination retry budget while retaining completed checkpoints', async () => {
const { ctx, adapter } = harness({ compactionRetries: 2 })
const calls = vi.spyOn(adapter, 'stream')
for (const length of [1_600, 1_500, 1_400]) {
const summary = new ScriptedAdapter([{ type: 'text', text: 'm'.repeat(length) }])
calls.mockImplementationOnce(options => summary.stream(options))
}
const session = conversation(4, 'fixture '.repeat(80))
const previous = session.requestHeader()
const switching = request(ctx, session, { provider: 'small', model: 'small' })
await expect(switching).rejects.toThrow('still above threshold after 3 compaction attempts')
expect(calls).toHaveBeenCalledTimes(3)
expect(session.requestHeader()).toEqual(previous)
expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(3)
expect(session.snapshotEvents().filter(event => event.type === 'compaction/end')).toHaveLength(3)
})
it('retains an explicit summary target when switching models', async () => {
const { ctx, adapter } = harness({ summarizationProvider: 'summary', summarizationModel: 'summary' })
ctx.llm.registerAdapter(['summary'], adapter)
@@ -1735,11 +1821,14 @@ describe('model-switch pressure', () => {
expect(adapter.lastOptions).toMatchObject({ provider: 'summary', model: 'summary' })
})
it('skips initial, unchanged, below-threshold, disabled and already cancelled requests', async () => {
it('skips initial, incomplete, unchanged, below-threshold, disabled and already cancelled requests', async () => {
const { ctx, adapter } = harness()
const selected = { provider: 'small', model: 'small' }
await request(ctx, Session.create(SessionId('initial-switch')), selected)
await request(ctx, conversation(4), { provider: MODEL, model: MODEL })
for (const config of [{ provider: '', model: 'small' }, { provider: 'small', model: '' }]) {
await expect(request(ctx, conversation(4), config)).resolves.toEqual(config)
}
await request(ctx, conversation(1), selected)
await request(ctx, conversation(4), selected, AbortSignal.abort('cancelled'))
expect(adapter.lastOptions).toBeUndefined()
@@ -1,4 +1,4 @@
# Proactive compaction with a bounded message budget.
# The second request selects a smaller model before its header is committed.
- id: agent-instructions
disabled: true