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

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	apps/web/tests/code-mode-fixture.snapshot.ts
#	docs/architecture.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/module-graph.md
#	packages/README.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/test-runtime/tests/runtime.spec.tsx
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.zh.md
#	packages/compact/compact-basic/src/summarizer.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
#	packages/ui/tui/README.i18n.yaml
#	pnpm-lock.yaml
This commit is contained in:
creatixchu
2026-07-30 10:36:41 +08:00
935 changed files with 41976 additions and 6862 deletions

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md
README.md: e7467721252f5a0b007390bef4bf86677091547a
README.zh.md: 07d9500f08b77f4e9d44ecb3745696734b0a1636
README.zh.md: 5e3258fba65f2189fc5e6c48654c11e46e779a0d

View File

@@ -4,28 +4,28 @@
**基础压缩compaction后端**`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV cache可在 `llm/stream` 处拦截)。
这是压缩能力的实现层。seam 见 [接口包](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
这是压缩能力的实现层。seam 见 [接口包package](../compact/README.md),设计见 [能力 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
## 拥有的职责
该后端拥有压缩策略:
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此步骤边界压力会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering。
- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍只提供建议,不会被咨询
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤检查绝不剪枝。
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 保持工具调用/结果 cut 平衡。轮次边界不会保护失控轮次内的旧步骤。开启且不可分的尾部在关闭前会拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上,计量最新一份规范已记录 envelope 与当前表层的 token 用量。因此,步骤边界压力计量会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering(中途引导)
- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍仅供参考,不参与此处的策略解析
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则已剪枝表层进行摘要。低于压力的步骤检查绝不剪枝。
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 将切分点调整到工具调用/结果配对平衡的位置。轮次边界不会保护失控轮次内的旧步骤。尚未闭合且不可分的尾部会在闭合前拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent(智能体)目标,而不运行仅用于 loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息包括图片引用并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`适配器可将其作为请求归因转发DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见体。只有返回文本会进入检查点;会排除可能泄露私有推理或产生遗留调用的 reasoning 与工具调用;图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息包括图片引用并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`适配器可将其作为请求归因转发DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回文本会进入检查点;推理reasoning和工具调用都会被排除以免泄露私有推理或产生遗留调用;图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
- **生命周期**`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、精确目标上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**不匹配`compact/start`惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性压力失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威
- **生命周期**`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**未配对`compact/start`不起作用的崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。即使已经取得进展,取消仍具有最终决定权
受保护的 `summarize()` 方法是唯一的子类 hook。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍位于 `ctx.tokenMeter`。hook 会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍 `ctx.tokenMeter` 负责。钩子会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。
## 配置(`BasicCompactConfig`
所有设置都可选。顶层策略字段是每个已路由模型的默认值;`modelPolicies` 对精确提供方模型对应用部分覆盖。出现压力时compact-basic 会请求所属 LLM 适配器提供该路由的上下文容量,并解析绝对预算。无法识别的 key、重复目标、互斥保留形式,以及合并后的 `retainRatio` 不低于 `thresholdRatio`,都会使插件加载失败。不低于缩放后阈值的绝对 `retainTokens` 预算会在第一个可解析目标失败,因为该比较需要模型容量。
所有设置都可选。顶层策略字段是每个已路由模型的默认值;`modelPolicies` 对精确提供方模型对应用部分覆盖。出现压力时compact-basic 会请求所属 LLM(大语言模型)适配器提供该路由的上下文容量,并解析绝对预算。无法识别的配置键、重复目标、互斥保留形式,以及合并后的 `retainRatio` 不低于 `thresholdRatio`,都会使插件加载失败。不低于缩放后阈值的绝对 `retainTokens` 预算会在首次解析目标时导致失败,因为该比较需要模型容量。
| Key | 必填 | 含义 |
|---|---|---|
@@ -34,7 +34,7 @@
| `retainTokens` | 否 | 逐字保留的近期表层绝对预算;与 `retainRatio` 互斥,并且必须低于已解析阈值。 |
| `summarizationProvider` | 否(默认 `''` | 与 `summarizationModel` 一起设置;空对会解析为最新已记录请求目标,再回退到 `AgentOptions` 对。 |
| `summarizationModel` | 否(默认 `''` | 与 `summarizationProvider` 一起设置;空对会解析为最新已记录请求目标,再回退到 `AgentOptions` 对。 |
| `maxTokens` | 否(默认 `8192` | 摘要调用的提供方生成上限;可包含 reasoning token。 |
| `maxTokens` | 否(默认 `8192` | 摘要调用的提供方生成上限;可包含推理 token。 |
| `compactionRetries` | 否(默认 `1` | 压力仍高于阈值时,在首次尝试后进行的额外尝试次数。 |
| `maxOverflowRetries` | 否(默认 `1` | 规范上下文窗口溢出后的最大重试次数;`0` 只禁用恢复。 |
| `modelPolicies` | 否(默认 `[]` | 精确的 `{ provider, model, ...partialPolicy }` 覆盖;匹配使用两个字段,不依赖 `listModels()`。 |
@@ -60,9 +60,9 @@ export function apply(ctx: Context): void {
}
```
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型 pass。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)``ctx.compact.compactRegion(...)`
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)``ctx.compact.compactRegion(...)`
例如,同一个 compact 插件可以安全服务于容量不同的模型,并应用一项目标特定策略:
例如,同一个压缩插件可以安全服务于容量不同的模型,并应用一项目标特定策略:
```yaml
- name: '@deepseek-ai/dsh-compact-basic'
@@ -82,7 +82,7 @@ export function apply(ctx: Context): void {
#### 模型看到的内容
成功步骤越过阈值后,如果已加载可选 pruner超大工具结果会先被改写。如果仍需摘要下一个请求会收到下方检查点前导、一个空行、`<compacted-summary>`取决于数据的摘要以及 `</compacted-summary>`。溢出恢复会根据使表层前进的任何替换重建立即重试。检查点会替换已选较早范围,后面跟随已保留的近期单元。
成功步骤越过阈值后,如果已加载可选 pruner超大工具结果会先被改写。如果仍需摘要下一个请求会收到下方检查点前导、一个空行、`<compacted-summary>`根据数据生成的摘要以及 `</compacted-summary>`。溢出恢复会根据使表层前进的任何替换重建立即重试。检查点会替换已选较早范围,后面跟随已保留的近期单元。
##### 会话检查点前导
@@ -92,7 +92,7 @@ This is an automatically generated checkpoint condensing an earlier span of the
#### Token 影响
不依赖模型的剪枝可以完全避免辅助调用;否则它会在摘要替换较早范围之前缩减该调用的 transcript。替换会缩减未来输入历史而非追加第二份副本。摘要会保留到后续压缩将其替换但不可分的非工具单元仍可能超出预算。
不依赖模型的剪枝可以完全避免辅助调用;否则它会在摘要替换较早范围之前缩减该调用的 transcript(文本记录)。替换会缩减未来输入历史,而非追加第二份副本。摘要会保留到后续压缩将其替换,但不可分的非工具单元仍可能超出预算。
#### KV Cache 影响
@@ -153,9 +153,9 @@ Rules:
## 已知限制与暂缓事项
- **Meter 准确度遵循固定启发式规则**:可复用提供方用量缺失时,会回退到字符数加结构开销,而非精确 tokenization
- **计量准确度取决于固定启发式规则**:可复用提供方用量缺失时,会回退到字符数加结构开销,而非精确 token
- **溢出分类由适配器维护**:提供方措辞可能改变;两个 DeepSeek 适配器将当前可识别的上下文限制失败规范化为 `CONTEXT_WINDOW_EXCEEDED`
- **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**:恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。
- **`compactRegion` 要求开启轮次**在完全关闭的会话上手动调用会抛出异常「no open turn」而不是执行压缩。
- **摘要失败会保留最新持久表层**:任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。 `maxTokens` 的摘要截断(可能由隐藏 reasoning token 耗尽)遵循同一规则。
- **摘要调用没有 transcript 快照覆盖**`dsh-llm-replay``assistant/chunk` 事件派生调用,因此这次不含 chunk 的直接 `ctx.llm.stream()` 调用无法回放([seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中明确的暂缓回放基础设施)。
- **`compactRegion` 要求存在未结束的轮次**在完全关闭的会话上手动调用会抛出异常「no open turn」而不是执行压缩。
- **摘要失败会保留最新持久表层**:任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。因达到 `maxTokens` 而发生的摘要截断(隐藏推理 token 可能会耗尽该额度)遵循同一规则。
- **摘要调用没有 transcript 快照覆盖**`dsh-llm-replay``assistant/chunk` 事件派生调用,因此这次不含分片的直接 `ctx.llm.stream()` 调用无法回放([seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中明确的暂缓回放基础设施)。

View File

@@ -195,7 +195,7 @@ export class BasicCompactService extends CompactService {
// 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.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
// 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}; `
@@ -205,14 +205,14 @@ export class BasicCompactService extends CompactService {
return { kind: 'retry' }
}
ctx.logger.warn(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
// 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()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited.
// 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')

View File

@@ -49,7 +49,7 @@ export function selectCompactableRange(
let accumulated = 0
let keepFromIdx = pricedNodes.length
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
accumulated += pricedNodes[index]!.tokens
keepFromIdx = index
if (accumulated >= retainTokens) break
@@ -57,15 +57,15 @@ export function selectCompactableRange(
if (keepFromIdx === 0) return null
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx === 0) return null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const first = surfaceNodes[0]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const cutoff = surfaceNodes[keepFromIdx - 1]!
return { start: first, end: cutoff }
}
@@ -98,11 +98,11 @@ export async function compactSurfaceRegion(
`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// 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)`)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// 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)`)
}
@@ -126,7 +126,9 @@ export async function compactSurfaceRegion(
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal)
const {
summary, rawOutput, provider, model, maxTokens, usage,
} = await dependencies.summarize(summarizationInput, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
@@ -146,12 +148,14 @@ export async function compactSurfaceRegion(
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
@@ -192,7 +196,7 @@ function buildSummarizationInput(
const events = session.events
const regionMessages = shadowedSeqs
// shadowedSeqs are current surface seqs, so each is a valid log index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
.map(seq => session.deriveEventMessage(events[seq]!))
.filter((message): message is Message => message !== null)
return {
@@ -209,7 +213,7 @@ function inspectTurnTail(
let compactionInProgress = false
let compactionStateKnown = false
for (let index = events.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (!compactionStateKnown) {
if (event.type === 'compact/start') {

View File

@@ -6,7 +6,9 @@
import type { Context } from 'cordis'
import { createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } 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 {
@@ -85,9 +87,13 @@ export interface SummarizationInput {
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult {
summary: ContentBlock[]
/** Complete provider output before the text-only summary projection. */
rawOutput?: ContentBlock[]
provider: string
model: string
maxTokens?: number
/** Provider-reported usage for this summarization request. */
usage?: TokenUsage
}
/**
@@ -148,15 +154,18 @@ export async function summarizeWithLlm(
const error = finishError(assembler.finish)
if (error !== undefined) throw error
const summary = summaryText(assembler.blocks())
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,
provider: options.provider,
model: options.model,
maxTokens: config.maxTokens,
...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
}
}

View File

@@ -20,6 +20,7 @@ import type {
LlmResolvedModelInfo,
Message,
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
@@ -234,6 +235,8 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
class TestCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
rawOutput: ContentBlock[] | undefined
usage: TokenUsage | undefined
summaryProvider = 'summary-provider'
summaryModel = 'summary-model'
error: unknown
@@ -244,15 +247,24 @@ class TestCompactService extends BasicCompactService {
input: SummarizationInput,
_agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
): Promise<{
summary: ContentBlock[]
rawOutput?: ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: TokenUsage
}> {
this.calls.push({ input, signal })
this.mutateDuringSummary?.()
if (this.error !== undefined) throw this.error
return {
summary: this.summary,
...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
provider: this.summaryProvider,
model: this.summaryModel,
maxTokens: 123,
...this.usage === undefined ? {} : { usage: this.usage },
}
}
}
@@ -830,6 +842,11 @@ describe('optional model-free tool-result pruning', () => {
describe('compaction region transaction', () => {
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
const compact = service()
compact.rawOutput = [
{ type: 'reasoning', text: 'private compact thought' },
...compact.summary,
]
compact.usage = { inputTokens: 40, outputTokens: 5 }
const session = conversation(3)
const before = [...session.surface.nodes]
const result = await compact.compactRegion(
@@ -850,6 +867,8 @@ describe('compaction region transaction', () => {
provider: 'summary-provider',
model: 'summary-model',
maxTokens: 123,
rawOutput: compact.rawOutput,
usage: compact.usage,
})
const head = session.deriveMessages()[0]!
expect(head.content[0]?.type).toBe('text')
@@ -1089,6 +1108,7 @@ describe('compaction region transaction', () => {
class ScriptedAdapter extends LlmAdapter {
lastOptions: GenerateOptions | undefined
usage: TokenUsage | undefined
constructor(
private readonly blocks: readonly ContentBlock[],
@@ -1109,6 +1129,7 @@ class ScriptedAdapter extends LlmAdapter {
yield { type: 'block-end', index, block }
}
}
if (this.usage !== undefined) yield { type: 'usage', usage: this.usage }
yield { type: 'finish', reason: this.finish }
}
}
@@ -1118,7 +1139,14 @@ class ExposedCompactService extends BasicCompactService {
input: SummarizationInput,
owner: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
): Promise<{
summary: ContentBlock[]
rawOutput?: ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: TokenUsage
}> {
return this.summarize(input, owner, signal)
}
}
@@ -1151,13 +1179,20 @@ describe('default one-shot summarizer', () => {
maxTokens: 321,
})
const session = conversation(1)
adapter.usage = { inputTokens: 12, outputTokens: 3 }
const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL)
expect(output).toEqual({
summary: [{ type: 'text', text: 'public summary' }],
rawOutput: [
{ type: 'reasoning', text: 'private' },
{ type: 'text', text: 'public summary' },
{ type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
],
provider: MODEL,
model: MODEL,
maxTokens: 321,
usage: adapter.usage,
})
expect(adapter.lastOptions).toMatchObject({
provider: MODEL,