Merge pull request #6993 from deepseek-harness/fix/summary-overflow-recovery

fix(compaction): retry oversized summaries with smaller prefixes
This commit is contained in:
1tops authored and GitHub committed 2026-10-23 21:16:37 +08:00
commit e54b1326f8
27 files changed
+1048 -36

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
en: 9dde333b53c14f24
zh: 8959aafefc55f9a4
/compaction/the-service:
en: 6c613669a0a61c16
zh: e9f23445bc0a5537
en: 85c4e66d472b4569
zh: afb47f8bad9efa3a
/compaction/tool-result-pruning-outcomes:
en: 27091b0a3d454575
zh: e22431e27e1349f2
+2
View File
@@ -100,6 +100,8 @@ type ManualCompactionErrorCode =
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.
When an automatically selected or `/compact` summary receives `LlmError(CONTEXT_WINDOW_EXCEEDED)` and existing recovery listeners decline it, compaction-basic retries a smaller tool-balanced prefix after cancellation and stability checks. Only the successfully summarized prefix is replaced; a single indivisible unit that still overflows fails. Explicit `compactRegion(start, end)` calls retain their exact range. The [package contract](../../packages/compaction/compaction-basic/README.md) defines prefix sizing and retry costs.
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.
## Tool-result pruning outcomes
+2
View File
@@ -100,6 +100,8 @@ type ManualCompactionErrorCode =
压力压缩在 `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` 拥有阈值、保留尾部策略、溢出上限与失败处理。
自动选区或 `/compact` 的摘要收到 `LlmError(CONTEXT_WINDOW_EXCEEDED)` 且既有恢复监听器未处理时,compaction-basic 在取消与稳定性检查后重试更小的工具配对完整前缀。只替换成功完成摘要的前缀;单个不可拆分单元仍超限时失败。显式 `compactRegion(start, end)` 调用保持精确范围。[包约定](../../packages/compaction/compaction-basic/README.zh.md)定义前缀大小和重试成本。
该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于检查 seq 之前与之后的工具调用/结果配对。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;[包约定](../../packages/compaction/compaction/README.zh.md#tool-pairing-boundaries)定义其缓存行为。
## 工具结果剪枝产出
@@ -9,8 +9,8 @@
en: 3ba5f9e37ab36328
zh: 2390a55415d4c27b
/deepseek-ai-dsh-compaction-basic/summary:
en: 31202d573182cc31
zh: e0a0c96f67a9c1ba
en: 0facbb90535709b1
zh: 60b7c46f80bde10c
/deepseek-ai-dsh-compaction-basic/table-of-contents:
en: d152484eb41ac6b4
zh: bda879a892f240f0
@@ -27,8 +27,8 @@
en: 3446c54c062d7e34
zh: fb502c941e508ae0
/deepseek-ai-dsh-compaction-basic/use-this-package/what-happens-when-condensation-runs:
en: c872a8ef069596f0
zh: b3a48e309ae830d4
en: 7284ba61343adcdd
zh: efd0113ab8e8e5ce
/deepseek-ai-dsh-compaction-basic/use-this-package/switching-models:
en: 48978abc46da1afa
zh: 11931af05db7b41f
@@ -51,8 +51,8 @@
en: 967531b71453eb88
zh: c7919b46e36763e6
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/the-region-transaction:
en: f4330f6bff22d400
zh: 46e16c51bb66a0f7
en: febc0a9de3eabe5f
zh: a570cc5d171c31b7
/deepseek-ai-dsh-compaction-basic/understand-the-implementation/config-resolution:
en: 313e019b1a9f8aa8
zh: 3c75d11b02adb693
@@ -90,8 +90,8 @@
en: 39a362ee4890dbdb
zh: 3b98699120b1548e
/deepseek-ai-dsh-compaction-basic/model-experience/auxiliary-summarizer-request/token-effect:
en: 5e0679cb7cb4291a
zh: c9df8ebc526b05d9
en: ae2e2b748de72d77
zh: ba11a49cbc263dca
/deepseek-ai-dsh-compaction-basic/model-experience/auxiliary-summarizer-request/kv-cache-effect:
en: d31d3e3d46542c91
zh: 5a42f873993e0c79
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
This package keeps long agent conversations working near the model's context limit. As token pressure builds, it condenses the oldest history into a summary while preserving recent messages; after a context-overflow error, it condenses and retries. You can also request condensation with `/compact` and optionally trim oversized tool outputs first. Condensation uses one extra model request and retains only its summary text. It cannot reduce the system prompt, tools, or session prefix, or split one indivisible unit such as a single huge tool call.
This package keeps long agent conversations working near the model's context limit. As token pressure builds, it condenses the oldest history into a summary while preserving recent messages; after a context-overflow error, it condenses and retries. You can also request condensation with `/compact` and optionally trim oversized tool outputs first. Each condensation attempt uses an extra model request; only successful summary text is retained. It cannot reduce the system prompt, tools, or session prefix, or split one indivisible unit such as a single huge tool call.
## Table of Contents
@@ -83,6 +83,8 @@ Misconfiguration fails fast: unknown settings, duplicate per-model overrides, in
The oldest balanced span is replaced by one summary message and the recent tail stays verbatim; the conversation continues from the summary. The operation reports how many history items were condensed and the estimated tokens freed. If nothing can be condensed safely — for example the whole conversation is one indivisible unit — nothing changes and nothing is written to the session log. If no model is available to write the summary (no configured target and no routed request yet), condensation fails with a clear error telling you to configure the summarization provider and model or route one request.
If an automatically selected or `/compact` summary receives `CONTEXT_WINDOW_EXCEEDED`, condensation retries the first half of the selected complete history units, rounded up to a whole unit, keeping each tool call with its results. Each further overflow applies the same reduction; a single unit that still overflows fails. A successful checkpoint replaces only the prefix actually summarized, leaving the remaining messages and images intact.
### Switching models
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.
@@ -130,7 +132,9 @@ A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap
### The region transaction
Failed summary requests dispatch synchronous `compaction/summary-error` after checking cancellation and selection stability. A recovery listener must record a durable input change before requesting retry. The backend re-derives the selected messages and refreshes their token prices and shrink baseline. The image-offload plugin owns image selection; its recorded omissions remain effective if the summary later fails or is cancelled.
Failed summary requests dispatch synchronous `compaction/summary-error` after checking cancellation and selection stability. A recovery listener must record a durable input change before requesting retry. The backend re-derives the selected messages and refreshes their token prices and shrink baseline. The image-offload plugin owns image selection; its recorded omissions remain effective if the summary later fails or is cancelled. When no listener handles a canonical context overflow, self-selected ranges may shrink within the same transaction; only the successful prefix appears in the checkpoint's cited range.
Explicit `compactRegion(start, end)` calls keep their exact selection and propagate summary overflow.
The transaction validates the surface span and the durable lock, appends `compaction/start`, summarizes through the hook, revalidates stability (whole-surface for automatic calls, selected-span for manual calls), rejects a summary that does not shrink its source, appends `compaction/summary` plus the replacement `user/message`, and makes exactly one `compaction/end` attempt. A live unmatched 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`. A failed close deliberately leaves a blocking orphan. Cancellation remains authoritative after cleanup and durability.
@@ -236,7 +240,7 @@ Rules:
#### 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.
Each summary attempt is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries and smaller-prefix retries can pay this cost more than once.
#### KV Cache effect
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
本包让长时 agent(智能体)会话在接近模型上下文上限时仍能正常工作。token 压力上升时,它会把最旧的历史压缩为摘要并保留近期消息;上下文溢出错误发生后,它会压缩并重试。你也可以通过 `/compact` 按需压缩,并选择先修剪超大工具输出。压缩使用一次额外的模型请求,并且只保留该请求返回的摘要文本。它无法缩减系统提示词、工具或会话前缀,也无法拆分单个不可分单元(例如一次超大工具调用)。
本包让长时 agent(智能体)会话在接近模型上下文上限时仍能正常工作。token 压力上升时,它会把最旧的历史压缩为摘要并保留近期消息;上下文溢出错误发生后,它会压缩并重试。你也可以通过 `/compact` 按需压缩,并选择先修剪超大工具输出。每次压缩尝试使用一次额外的模型请求;只保留成功返回的摘要文本。它无法缩减系统提示词、工具或会话前缀,也无法拆分单个不可分单元(例如一次超大工具调用)。
## 目录
@@ -83,6 +83,8 @@ kind: "package-reference"
最旧的平衡范围会被替换为一条摘要消息,近期尾部保持逐字不变;对话从摘要继续。操作会报告压缩了多少历史项以及估算释放的 token 数。如果没有任何内容可以安全压缩——例如整个对话就是一个不可分单元——则不会有任何改变,也不会向会话日志写入任何内容。如果没有模型可以撰写摘要(既未配置目标,也还没有已路由请求),压缩会失败并给出清晰错误,提示你配置摘要提供方与模型,或先路由一次请求。
自动选区或 `/compact` 的摘要收到 `CONTEXT_WINDOW_EXCEEDED` 时,压缩会重试所选完整历史单元的前一半(单元数向上取整),每个工具调用及其结果保持在一起。每次再次超限都按同一规则缩小范围;单个单元仍超限时失败。成功检查点只替换实际完成摘要的前缀,其余消息和图片保持不变。
### 切换模型
在提供方或模型发生变化的请求写入日志前,自动压缩会按目标模型的容量、输出预留和压力策略测量现有历史。需要压缩时,使用原路由模型及其摘要设置;显式配置的摘要目标仍然优先。只有历史降到目标阈值以下,才会继续目标请求。失败会报告 `MODEL_SWITCH_COMPACTION_FAILED`,并保留原请求头;可选择更大窗口的模型,或修正报告的原因后重试。即使后续尝试失败,已经完成的裁剪或压缩仍保留在会话中。检查在下次请求准备时执行,`auto: false` 会禁用它。
@@ -130,7 +132,9 @@ kind: "package-reference"
### 区域事务
摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。
摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。没有监听器处理规范上下文超限时,自选范围可以在同一事务中缩小;检查点只引用成功摘要的前缀范围。
显式 `compactRegion(start, end)` 调用保持精确选区,并向调用方报告摘要超限。
事务验证表层范围与持久锁,追加 `compaction/start`,通过钩子生成摘要,重新验证稳定性(自动调用要求整个表层、手动调用只要求所选范围),拒绝不缩小源内容的摘要,追加 `compaction/summary` 与替换 `user/message`,并恰好进行一次 `compaction/end` 尝试。活动的未匹配 start 是持久锁:位于较新 `session/end-seed` 之前的未匹配标记是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。闭合失败会有意留下阻塞性的未匹配标记。完成清理与持久化后,取消仍具有最终决定权。
@@ -236,7 +240,7 @@ Rules:
#### Token 影响
这是一次独立模型调用:输入是已回放会话前缀加固定指令,输出受 `maxTokens` 限制。收敛重试可能多次支付这项成本。
每次摘要尝试都是一次独立模型调用:输入是已回放会话前缀加固定指令,输出受 `maxTokens` 限制。收敛重试和缩小前缀后的重试可能多次支付这项成本。
#### KV Cache 影响
@@ -304,7 +304,7 @@ export class BasicCompactionEngine extends CompactionEngine {
const measurement = this.ctx.tokenMeter.measure(agent.session)
const range = selectCompactableRange(agent.session, measurement, 0)
if (range === null) return null
return this.compactRegion(range.start, range.end, agent, signal)
return this.compactAutomaticRegion(range.start, range.end, agent, signal)
}
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
@@ -359,7 +359,7 @@ export class BasicCompactionEngine extends CompactionEngine {
+ `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`,
)
}
result = await this.compactRegion(range.start, range.end, agent, signal)
result = await this.compactAutomaticRegion(range.start, range.end, agent, signal)
measurement = meter.measure(agent.session, header)
if (measurement.totalTokens < spec.thresholdTokens) return result
}
@@ -370,8 +370,26 @@ export class BasicCompactionEngine extends CompactionEngine {
)
}
/** Compact an automatically selected prefix, allowing smaller balanced prefixes on summary overflow. */
private compactAutomaticRegion(
start: SessionSeq,
end: SessionSeq,
agent: Agent,
signal: AbortSignal,
): Promise<CompactionResult> {
return compactSurfaceRegion(
this.regionDependencies(),
agent.session,
start,
end,
agent,
{ owner: 'current-turn', stability: 'whole-surface', allowSmallerPrefix: true },
signal,
)
}
/**
* Compact one inclusive positional range from the agent-owned surface using
* Compact one exact 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.
@@ -430,6 +448,7 @@ export class BasicCompactionEngine extends CompactionEngine {
{
owner: null,
stability: 'selected-span',
allowSmallerPrefix: true,
...sourceCommandId === undefined ? {} : { sourceCommandId },
flush: async () => {
await this.ctx.sessions.flush(agent.session)
@@ -16,7 +16,7 @@ import {
} 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 { CONTEXT_WINDOW_EXCEEDED_CODE, createUserMessage, errorChain, LlmError } 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 { SessionSeq, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -61,6 +61,8 @@ interface CompactionTransactionOptions {
readonly flush?: () => Promise<void>
/** Manual command that initiated this transaction, when present. */
readonly sourceCommandId?: CommandId
/** Allow a self-selected range to shrink after summary overflow; absent keeps the exact range. */
readonly allowSmallerPrefix?: true
}
interface CompactionEntryState {
@@ -166,7 +168,8 @@ export function selectCompactableRange(
* @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 options - bracket owner, stability rule, self-selected prefix shrink policy,
* optional source-command correlation, and optional durability checkpoint.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
@@ -227,6 +230,7 @@ export async function compactSurfaceRegion(
compactionId,
options.sourceCommandId,
assertStable,
options.allowSmallerPrefix === true,
signal,
)
if (options.owner === null) signal?.throwIfAborted()
@@ -391,6 +395,7 @@ async function summarizeCompaction(
compactionId: CompactionResult['compactionId'],
sourceCommandId: CommandId | undefined,
assertStable: StabilityCheck,
allowSmallerPrefix: boolean,
signal?: AbortSignal,
): Promise<SummarizedCompaction> {
let summaryResult: SummaryResult
@@ -402,9 +407,16 @@ async function summarizeCompaction(
} catch (error: unknown) {
if (signal?.aborted === true) throw error
assertStable(dependencies, agent.session, prepared)
if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error
prepared = prepareCompaction(dependencies, agent.session,
validateSurfaceRegion(agent.session, prepared.start, prepared.end))
let selection: SurfaceSelection
if (dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) {
selection = validateSurfaceRegion(agent.session, prepared.start, prepared.end)
} else {
if (!allowSmallerPrefix || !(error instanceof LlmError) || error.code !== CONTEXT_WINDOW_EXCEEDED_CODE) throw error
const smaller = smallerBalancedPrefix(agent.session, prepared)
if (smaller === null) throw error
selection = smaller
}
prepared = prepareCompaction(dependencies, agent.session, selection)
}
}
const checkpointMessage = createUserMessage({
@@ -427,6 +439,16 @@ async function summarizeCompaction(
}
}
/** Halve complete tool-balanced units; one remaining unit cannot be shortened safely. */
function smallerBalancedPrefix(session: Session, selection: SurfaceSelection): SurfaceSelection | null {
const ends = selection.shadowedSeqs.filter(seq => toolPairingBalancedAfter(session, seq))
if (ends.length < 2) return null
// A validated span ends balanced; two or more units leave a nonempty proper prefix.
// oxlint-disable-next-line typescript/no-non-null-assertion
const end = ends[Math.ceil(ends.length / 2) - 1]!
return validateSurfaceRegion(session, selection.start, end)
}
/** Reject a summary prepared against any earlier surface generation. */
function assertWholeSurfaceUnchanged(
dependencies: RegionDependencies,
@@ -13,7 +13,7 @@ import {
resolveTargetPolicy,
} from '@deepseek-ai/dsh-compaction-basic/src/config.ts'
import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createSystemMessage, createToolResultMessage, LlmAdapter , createMessage } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createSystemMessage, createToolResultMessage, LlmAdapter, LlmError, createMessage } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
@@ -850,15 +850,71 @@ describe('pressure measurement and retention', () => {
expect(await compactIfNeeded(compact, retained)).toBeNull()
})
it('uses one unified measurement for each pressure-and-retention decision', async () => {
it('preserves history when a pressure summary fails', async () => {
const ctx = createContext()
const compact = service(compactConfig, ctx)
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
const stop = new Error('stop after first decision')
vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop)
const session = conversation(4)
const originalNodes = [...session.surface.nodes]
const stop = new Error('summary failed')
compact.error = stop
await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop)
expect(measure).toHaveBeenCalledTimes(1)
await expect(compactIfNeeded(compact, session)).rejects.toBe(stop)
expect(compact.calls).toHaveLength(1)
expect(session.surface.nodes).toEqual(originalNodes)
expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toEqual([])
expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'compaction/end', data: { error: 'summary failed' } })
})
it('shares one pressure-and-retention measurement before opening the compaction transaction', async () => {
const ctx = createContext()
try {
const compact = service(compactConfig, ctx)
const session = conversation(4)
const measuredRevisions: number[] = []
const originalMeasure = ctx.tokenMeter.measure.bind(ctx.tokenMeter)
const measure = vi.spyOn(ctx.tokenMeter, 'measure').mockImplementation((measuredSession, header) => {
measuredRevisions.push(measuredSession.seq)
return originalMeasure(measuredSession, header)
})
const failure = new Error('summary failed')
compact.error = failure
await expect(compactIfNeeded(compact, session, 'pressure')).rejects.toBe(failure)
const start = session.snapshotEvents().find(event => event.type === 'compaction/start')
if (start === undefined) throw new Error('pressure must open a compaction transaction')
expect(measuredRevisions.filter(revision => revision <= start.seq)).toHaveLength(1)
expect(measure.mock.calls[0]).toEqual([session, session.requestHeader()])
expect(measuredRevisions.some(revision => revision > start.seq)).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('recovers a pressure summary overflow below threshold while retaining the excluded suffix', async () => {
const ctx = createContext()
try {
const compact = service({ auto: false, thresholdRatio: 0.65, retainTokens: 180, compactionRetries: 0 }, ctx)
const session = conversation(4)
const originalNodes = [...session.surface.nodes]
const originalMessages = session.deriveMessages()
expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(650)
compact.mutateDuringSummary = () => {
if (compact.calls.length === 1) throw new LlmError('summary input exceeds the model context', CONTEXT_WINDOW_EXCEEDED_CODE)
}
const result = await compactIfNeeded(compact, session, 'pressure')
expect(compact.calls.map(call => call.input.messages.length)).toEqual([6, 3])
expect(result?.shadowedSeqs).toEqual(originalNodes.slice(0, 3))
expect(session.surface.nodes.slice(1)).toEqual(originalNodes.slice(3))
expect(session.deriveMessages().slice(1)).toEqual(originalMessages.slice(3))
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(650)
expect(session.snapshotEvents().filter(event => event.type.startsWith('compaction/')).map(event => event.type))
.toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
} finally {
await ctx.fiber.dispose()
}
})
it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
@@ -8,9 +8,11 @@ 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 {
CONTEXT_WINDOW_EXCEEDED_CODE,
createAssistantMessage,
createUserMessage,
LlmAdapter,
LlmError,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
@@ -239,6 +241,54 @@ function compactEvents(session: Session): SessionEvent[] {
}
describe('compactNow through the real loop', () => {
it('recovers an idle summary overflow and flushes before releasing a queued prompt', async () => {
const harness = await loopHarness()
const { ctx, agent, compact, adapter, log } = harness
const retryEntered = deferred()
const gate = deferred()
let running: Promise<CompactionResult | null> | undefined
try {
for (let turn = 0; turn < 3; turn += 1) await seedHistory(harness)
const originalNodes = [...agent.session.surface.nodes]
const originalMessages = agent.session.deriveMessages()
const flush = vi.spyOn(ctx.sessions, 'flush')
compact.duringSummary = () => {
if (compact.calls.length === 1) throw new LlmError('summary input exceeds the model context', CONTEXT_WINDOW_EXCEEDED_CODE)
compact.gate = gate.promise
retryEntered.resolve()
}
running = compact.compactNow(agent, SIGNAL)
await Promise.race([retryEntered.promise, running])
expect(compact.calls.map(input => input.messages.length)).toEqual([6, 4])
expect(log).toEqual(['compaction/start:null'])
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'Continue after the smaller checkpoint.' }], source: { kind: 'user' },
}))
expect(adapter.requests).toHaveLength(3)
gate.resolve()
const result = await running
expect(result?.shadowedSeqs).toEqual(originalNodes.slice(1, 4))
expect(flush).toHaveBeenCalledWith(agent.session)
await agent.whenIdle()
const events = compactEvents(agent.session)
expect(events.map(event => event.type)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
expect(events[0]?.data).toMatchObject({ turn: null })
expect(events[2]?.data).toMatchObject({ turn: null })
expect(log.indexOf('compaction/end:null')).toBeLessThan(log.indexOf('flush'))
expect(log.indexOf('flush')).toBeLessThan(log.indexOf('turn/start'))
expect(adapter.requests).toHaveLength(4)
expect(adapter.requests[3]?.slice(2, -1)).toEqual(originalMessages.slice(4))
expect(adapter.requests[3]?.at(-1)?.content).toEqual([{ type: 'text', text: 'Continue after the smaller checkpoint.' }])
} finally {
gate.resolve()
if (running !== undefined) await Promise.allSettled([running])
await ctx.fiber.dispose()
}
})
it('passes logged image omissions to the summarizer without changing the original message', async () => {
const { ctx, agent, compact } = await loopHarness()
try {
@@ -0,0 +1,116 @@
/** Summary overflow recovery through the real agent loop and image projections. */
import { describe, expect, it } 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 { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import {
CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ToolCallId, createUserMessage,
} from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import * as imageOffload from '@deepseek-ai/dsh-compaction-image-offload'
import BasicCompactionEngine from '../src/index.ts'
type ImageBlock = Extract<ContentBlock, { type: 'image' }>
function image(name: string): ImageBlock {
return {
type: 'image',
attachment: {
attachmentId: `sha256:${'a'.repeat(64)}` as ImageBlock['attachment']['attachmentId'],
name, mediaType: 'image/png', bytes: 1024, width: 1920, height: 1080,
},
}
}
function imageNames(options: GenerateOptions): string[] {
return options.messages.flatMap(message => message.content.flatMap(block =>
block.type === 'image' && block.offloaded !== true ? [block.attachment.name ?? ''] : []))
}
/** Capacity is computed from valid image occurrences; successful calls report exact fixture usage. */
class ImageCapacityAdapter extends LlmAdapter {
readonly requests: Array<{ purpose: string; images: string[]; overflow: boolean }> = []
private toolIssued = false
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'], context: { contextWindow: 10_000 } })
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const images = imageNames(options)
const textTokens = options.messages.reduce((total, message) => total + message.content.reduce((sum, block) =>
sum + (block.type === 'text' ? Math.ceil(block.text.length / 4) : 0), 4), 0)
const inputTokens = images.length * 2000 + textTokens
const overflow = inputTokens + (options.maxTokens ?? 0) > 10_000
this.requests.push({ purpose: options.purpose ?? 'conversation', images, overflow })
if (overflow) throw new LlmError('fixture image context exceeds the model window', CONTEXT_WINDOW_EXCEEDED_CODE)
const lastText = options.messages.at(-1)?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
const callsTool = options.purpose !== 'compaction' && lastText === 'Collect two more screenshots.' && !this.toolIssued
if (callsTool) {
this.toolIssued = true
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('capture'), name: 'capture_images', arguments: '{}' } }
} else {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: options.purpose === 'compaction' ? 'Earlier screenshots were reviewed.' : 'Received.' } }
}
yield { type: 'usage', usage: { inputTokens, outputTokens: 10 } }
yield { type: 'finish', reason: { kind: callsTool ? 'tool-calls' : 'stop' } }
}
}
describe('summary overflow through the agent loop', () => {
it('summarizes a smaller prefix and retries with the untouched image suffix', async () => {
const ctx = new Context()
const adapter = new ImageCapacityAdapter()
try {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(imageOffload)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeter)
ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
ctx.effect(() => ctx.tools.register(defineContentToolFixture({
name: 'capture_images', description: 'Return two screenshot references', parameters: {},
async execute(_args, exec) {
if (exec.agent === undefined) throw new Error('image fixture requires an agent')
// Steering makes the complete tool exchange eligible for prefix compaction.
exec.agent.steer(createUserMessage({ content: [{ type: 'text', text: 'Continue inspecting the screenshots.' }], source: { kind: 'user' } }))
return [image('recent-a'), image('recent-b')]
},
})))
await ctx.plugin(BasicCompactionEngine, { headroomTokens: 0, thresholdRatio: 1, retainTokens: 100, maxTokens: 256 })
const agent = await ctx.agentLoop.create(SessionId('image-summary-overflow'), { provider: 'mock', model: 'vision', maxTokens: 256 })
for (const label of ['old', 'middle']) {
agent.followup(createUserMessage({
content: [{ type: 'text', text: `${label}: ${'dashboard details '.repeat(40)}` }, image(`${label}-a`), image(`${label}-b`)],
source: { kind: 'user' },
}))
await agent.whenIdle()
expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
}
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Collect two more screenshots.' }], source: { kind: 'user' } }))
await agent.whenIdle()
const summaries = adapter.requests.filter(request => request.purpose === 'compaction')
expect(summaries).toHaveLength(2)
expect(summaries[0]).toMatchObject({ overflow: true, images: ['old-a', 'old-b', 'middle-a', 'middle-b', 'recent-a', 'recent-b'] })
expect(summaries[1]).toMatchObject({ overflow: false, images: ['old-a', 'old-b', 'middle-a', 'middle-b'] })
expect(adapter.requests.at(-1)).toMatchObject({ purpose: 'conversation', overflow: false, images: ['recent-a', 'recent-b'] })
const events = agent.session.snapshotEvents()
expect(events.filter(event => event.type === 'compaction/start')).toHaveLength(1)
expect(events.filter(event => event.type === 'compaction/end')).toHaveLength(1)
expect(events.filter(event => event.type === 'compaction/summary')).toHaveLength(1)
expect(events.filter(event => event.type === 'image/offload')).toEqual([])
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
expect(agent.session.surface.replaceGeneration).toBe(1)
} finally {
await ctx.fiber.dispose()
}
})
})
@@ -0,0 +1,250 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CONTEXT_WINDOW_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import { compactSurfaceRegion } from '../src/region.ts'
import type { SummarizationInput, SummaryResult } from '../src/summarizer.ts'
const contexts: Context[] = []
afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) })
const historyText = 'A retained source fact with enough detail for a smaller checkpoint. '.repeat(80)
function overflow(): LlmError {
return new LlmError('summary input exceeds the model context', CONTEXT_WINDOW_EXCEEDED_CODE)
}
function summary(text = 'The older task details are summarized.'): SummaryResult {
return { summary: [{ type: 'text', text }], provider: 'test', model: 'summary' }
}
/** Each tool unit contains two calls and both results; user units contain one message. */
function harness(units: readonly ('user' | 'tools')[]) {
const ctx = new Context()
contexts.push(ctx)
new SessionProjectionRegistry(ctx)
const meter = new TokenMeter(ctx)
const session = Session.create(SessionId('summary-overflow'))
session.append('turn/start', { turn: 1 })
const head = session.append('system/message', {
turn: 1, step: 1, message: createSystemMessage(historyText),
}, { surfaceOp: 'append' })
for (const [index, unit] of units.entries()) {
if (unit === 'user') {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${index}: ${historyText}` }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
continue
}
const step = index + 1
const callIds = [ToolCallId(`call-${index}-a`), ToolCallId(`call-${index}-b`)]
session.append('step/start', { turn: 1, step })
session.append('assistant/message', {
turn: 1, step, stream: [],
message: createAssistantMessage({
source: { provider: 'test', model: 'test' },
content: callIds.map(id => ({ type: 'tool-call', id, name: 'read', arguments: '{}' })),
}),
}, { surfaceOp: 'append' })
for (const callId of callIds) {
session.append('tool/call', { turn: 1, step, callId, name: 'read', arguments: '{}' })
session.append('tool/result', {
turn: 1, step,
message: createToolResultMessage({ callId, content: [{ type: 'text', text: historyText }], isError: false }),
}, { surfaceOp: 'append' })
}
session.append('step/end', { turn: 1, step })
}
const selected = session.surface.nodes.slice(1)
const start = selected[0]
const end = selected.at(-1)
if (start === undefined || end === undefined) throw new Error('fixture needs a nonempty selection')
const tail = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Keep the newest request verbatim.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const agent = { session, options: { provider: 'test', model: 'test' } } as Agent
const summarize = vi.fn<(input: SummarizationInput) => Promise<SummaryResult>>()
const recover = vi.fn<Parameters<typeof compactSurfaceRegion>[0]['recover']>(() => false)
const requests: SummarizationInput[] = []
const run = (allowSmallerPrefix = true, signal?: AbortSignal, owner: 'current-turn' | null = 'current-turn') => compactSurfaceRegion({
meter,
summarize(input) { requests.push(input); return summarize(input) },
recover,
}, session, start, end, agent, {
owner, stability: owner === null ? 'selected-span' : 'whole-surface',
...allowSmallerPrefix ? { allowSmallerPrefix: true as const } : {},
}, signal)
return { session, head, tail, selected, requests, summarize, recover, run }
}
function sourceCount(input: SummarizationInput): number {
return input.messages.filter(message => message.role !== 'system').length
}
function compactionTypes(session: Session): string[] {
return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')).map(event => event.type)
}
describe('summary context overflow', () => {
it.each([8, 9])('halves complete prefixes until one fits and preserves the excluded suffix: %s units', async (count) => {
const h = harness(Array.from({ length: count }, () => 'user'))
h.summarize.mockImplementation(async (input) => {
if (sourceCount(input) > 2) throw overflow()
return summary()
})
const result = await h.run()
expect(h.requests.map(sourceCount)).toEqual(count === 8 ? [8, 4, 2] : [9, 5, 3, 2])
expect(result.shadowedSeqs).toEqual(h.selected.slice(0, 2))
expect(h.session.surface.nodes.slice(2)).toEqual([...h.selected.slice(2), h.tail.seq])
expect(h.session.surface.nodes[0]).toBe(h.head.seq)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
})
it('counts a tool-call message and all its results as one indivisible unit', async () => {
const h = harness(['tools', 'user', 'user', 'user'])
h.summarize.mockRejectedValueOnce(overflow()).mockResolvedValue(summary())
const result = await h.run()
expect(h.requests.map(sourceCount)).toEqual([6, 4])
expect(h.requests[1]?.messages.map(message => message.role)).toEqual(['system', 'assistant', 'tool', 'tool', 'user'])
expect(result.shadowedSeqs).toEqual(h.selected.slice(0, 4))
expect(h.session.surface.nodes.slice(2)).toEqual([...h.selected.slice(4), h.tail.seq])
})
it.each(['user', 'tools'] as const)('stops at one indivisible %s unit without committing a checkpoint', async (unit) => {
const h = harness([unit])
const error = overflow()
h.summarize.mockRejectedValue(error)
const original = [...h.session.surface.nodes]
await expect(h.run()).rejects.toBe(error)
expect(h.summarize).toHaveBeenCalledTimes(1)
expect(h.session.surface.nodes).toEqual(original)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
it('exhausts progressively smaller prefixes when even the first unit overflows', async () => {
const h = harness(Array.from({ length: 8 }, () => 'user'))
const error = overflow()
h.summarize.mockRejectedValue(error)
const original = [...h.session.surface.nodes]
await expect(h.run()).rejects.toBe(error)
expect(h.requests.map(sourceCount)).toEqual([8, 4, 2, 1])
expect(h.session.surface.nodes).toEqual(original)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
it('keeps an explicitly requested range exact', async () => {
const h = harness(['user', 'user', 'user', 'user'])
const error = overflow()
h.summarize.mockRejectedValue(error)
await expect(h.run(false)).rejects.toBe(error)
expect(h.summarize).toHaveBeenCalledTimes(1)
})
it.each([
new LlmError('credentials expired', 'AUTH'),
new LlmError('provider unavailable', 'SERVER'),
new LlmError('summary truncated', 'MAX_TOKENS'),
Object.assign(new Error('unclassified failure'), { code: CONTEXT_WINDOW_EXCEEDED_CODE }),
])('does not shorten for other failures: %s', async (error) => {
const h = harness(['user', 'user', 'user', 'user'])
h.summarize.mockRejectedValue(error)
await expect(h.run()).rejects.toBe(error)
expect(h.summarize).toHaveBeenCalledTimes(1)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
it('does not retry or invoke recovery after cancellation', async () => {
const h = harness(['user', 'user', 'user', 'user'])
const controller = new AbortController()
const error = overflow()
h.summarize.mockImplementation(async () => { controller.abort('cancelled'); throw error })
await expect(h.run(true, controller.signal)).rejects.toBe(error)
expect(h.summarize).toHaveBeenCalledTimes(1)
expect(h.recover).not.toHaveBeenCalled()
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
it('rejects a changed surface before considering a smaller selection', async () => {
const h = harness(['user', 'user', 'user', 'user'])
h.summarize.mockImplementation(async () => {
h.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'concurrent input' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
throw overflow()
})
await expect(h.run()).rejects.toThrow('session surface changed during summarization')
expect(h.summarize).toHaveBeenCalledTimes(1)
expect(h.recover).not.toHaveBeenCalled()
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
it.each(['manual', 'automatic'] as const)('retains %s stability rules when the excluded suffix changes after shortening', async (mode) => {
const h = harness(Array.from({ length: 8 }, () => 'user'))
if (mode === 'manual') h.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replaced = h.selected[6]
if (replaced === undefined) throw new Error('fixture needs a suffix node')
let replacement = replaced
h.summarize.mockRejectedValueOnce(overflow()).mockImplementation(async () => {
replacement = h.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Updated excluded suffix' }], source: { kind: 'user' },
}), {
surfaceOp: { op: 'replace', startSeq: replaced, endSeq: replaced }, sourceEventSeqs: [replaced],
}).seq
return summary()
})
const operation = h.run(true, undefined, mode === 'manual' ? null : 'current-turn')
if (mode === 'manual') {
const result = await operation
expect(result.shadowedSeqs).toEqual(h.selected.slice(0, 4))
expect(h.session.surface.nodes.slice(2)).toEqual([
...h.selected.slice(4, 6), replacement, ...h.selected.slice(7), h.tail.seq,
])
expect(h.session.snapshotEvents().find(event => event.type === 'compaction/start')?.data).toMatchObject({ turn: null })
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
} else {
await expect(operation).rejects.toThrow('session surface changed during summarization')
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
}
expect(h.requests.map(sourceCount)).toEqual([8, 4])
})
it('allows a registered durable repair to retain the entire selection', async () => {
const h = harness(['user', 'user', 'user', 'user'])
h.summarize.mockRejectedValueOnce(overflow()).mockResolvedValue(summary())
h.recover.mockImplementationOnce(() => {
h.session.append('system/message', {
turn: 1, step: 1, message: createSystemMessage('Reduced system input'),
}, { surfaceOp: { op: 'replace', startSeq: h.head.seq, endSeq: h.head.seq }, sourceEventSeqs: [h.head.seq] })
return true
})
const result = await h.run()
expect(h.requests.map(sourceCount)).toEqual([4, 4])
expect(h.requests[1]?.messages[0]?.content).toEqual([{ type: 'text', text: 'Reduced system input' }])
expect(result.shadowedSeqs).toEqual(h.selected)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
})
it('preserves the original surface if a smaller summary fails for another reason', async () => {
const h = harness(['user', 'user', 'user', 'user'])
const error = new LlmError('provider unavailable', 'SERVER')
h.summarize.mockRejectedValueOnce(overflow()).mockRejectedValue(error)
const original = [...h.session.surface.nodes]
await expect(h.run()).rejects.toBe(error)
expect(h.requests.map(sourceCount)).toEqual([4, 2])
expect(h.session.surface.nodes).toEqual(original)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
it('compares the checkpoint with the smaller source span before committing', async () => {
const h = harness(['user', 'user', 'user', 'user'])
h.summarize.mockRejectedValueOnce(overflow()).mockResolvedValue(summary(historyText.repeat(3)))
const original = [...h.session.surface.nodes]
await expect(h.run()).rejects.toThrow('summary is not smaller than the shadowed content')
expect(h.requests.map(sourceCount)).toEqual([4, 2])
expect(h.session.surface.nodes).toEqual(original)
expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end'])
})
})
@@ -27,8 +27,8 @@
en: 6b11d5111e1a3222
zh: de4d3889b1a5ebb4
/deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants:
en: 64666ea2894acc4a
zh: 0e78df32bcfd3633
en: 5883fbcc1db3e5bf
zh: cf8e589dcd5474c3
/deepseek-ai-dsh-session-snapshot/use-this-package/what-can-go-wrong:
en: 0b03592947ca43d6
zh: b14a64238c4b5f80
@@ -90,6 +90,8 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j
A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants.
ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength` to an exact JSON-quoted cwd length. The harness resolves the parent directory through the native filesystem API before padding the cwd to that length and retains a unique random suffix. On Windows, directory allocation uses a namespace-prefixed path; the returned cwd and model requests retain the logical path whose JSON length was priced. The generated ASCII basename, including its fixed prefix and random suffix, is limited to 255 bytes. Before padding or allocating directories, the harness rejects lengths that are not safe integers, cannot accommodate the temporary root and generated basename, or would exceed that basename limit. Filesystem limits on the full path still apply. Other scenarios keep their ordinary temporary paths.
### What can go wrong
- **A durable wait times out** — `waitForTurnStart`, `waitForTurnEnd`, `waitForSubagentTurnEnd`, `waitForGoalPhase`, `waitForInboxMessage`, `waitForTitleAfterTurnEnd`, and `waitForEventAfterTurnEnd` identify the session or child and deadline even when the first log harvest exceeds that deadline. Child waits also name the requested turn. All retain the underlying failure as the error cause. `waitForTurnStart` rejects malformed turn records immediately.
@@ -90,6 +90,8 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创
需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。
持久记录 runtime-context 文本 token 计价的 ACP 场景可将 `workspaceCwdTextLength` 设为 cwd 的精确 JSON 引用字符串长度。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该长度,并保留唯一随机后缀。在 Windows 上,目录分配使用带命名空间前缀的路径;返回的 cwd 和模型请求仍使用计价时的逻辑路径,保持其 JSON 长度。生成的 ASCII 目录名(含固定前缀和随机后缀)限制为 255 字节。若指定长度不是安全整数、容不下临时根目录和生成的目录名,或会使目录名超过该上限,harness 会在填充字符串或分配目录前拒绝。文件系统对完整路径的限制仍然适用。其他场景保留普通临时路径。
### 可能出什么问题
- **持久记录等待超时**——即使首次日志收集就超过期限,`waitForTurnStart`、`waitForTurnEnd`、`waitForSubagentTurnEnd`、`waitForGoalPhase`、`waitForInboxMessage`、`waitForTitleAfterTurnEnd` 和 `waitForEventAfterTurnEnd` 也会指出会话或子会话及等待期限。子会话等待还会指出目标轮次。这些等待都通过错误的 cause 保留底层失败。`waitForTurnStart` 会立即拒绝格式错误的轮次记录。
@@ -20,7 +20,7 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync, realpathSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, dirname, join, delimiter } from 'node:path'
import { basename, dirname, join, delimiter, toNamespacedPath } from 'node:path'
import { vi } from 'vitest'
import {
PROTOCOL_VERSION,
@@ -211,6 +211,13 @@ export interface RunOptions {
* child, never the supplied parent.
*/
workspaceParent?: string
/**
* Exact JSON-quoted cwd length for fixtures that persist path-dependent token prices.
* Pads a natively resolved temporary path while retaining mkdtemp's random suffix;
* the generated ASCII basename is capped at 255 bytes, including the suffix.
* Lengths outside the resulting safe-integer range reject before padding or allocation.
*/
workspaceCwdTextLength?: number
/**
* Alternate live profile patch (absolute), overriding
* {@link AgentUnderTest.configPath} for this run. Its basename still ends
@@ -236,6 +243,23 @@ export function snapshotSpillRoot(
return `${root}/dsh-acp-snap-${key}`
}
/** Keep path-priced fixtures independent of the temporary root without sharing directories. */
function workspaceTemporaryPrefix(options: RunOptions): string {
const parent = options.workspaceParent ?? tmpdir()
const length = options.workspaceCwdTextLength
if (length === undefined) return join(parent, 'acp-snap-cwd-')
// Native resolution expands Windows short names before their characters are priced.
const prefix = join(realpathSync.native(parent), 'acp-snap-cwd-')
// mkdtemp contributes six characters; runtime-context renders the JSON-quoted cwd.
const minimum = JSON.stringify(`${prefix}XXXXXX`).length
// Cap the generated ASCII component, including mkdtemp's suffix, at 255 bytes.
const maximum = minimum + 255 - basename(prefix).length - 6
if (!Number.isSafeInteger(length) || length < minimum || length > maximum) {
throw new Error(`snapshot-harness: workspaceCwdTextLength must be a safe integer between ${minimum} and ${maximum}`)
}
return prefix + '-'.repeat(length - minimum)
}
/**
* Run a scenario end-to-end against a freshly-spawned subprocess. Owns the
* child and its generated dirs; always tears them down. Returns the captured stdout
@@ -246,7 +270,10 @@ export function snapshotSpillRoot(
* @returns The captured stdout/stderr, session id, generated cwd, and harvested logs.
*/
export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> {
const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-'))
const cwdPrefix = workspaceTemporaryPrefix(opts)
// Windows mkdtemp needs an explicit long-path namespace; requests retain the priced logical cwd.
const allocatedCwd = await mkdtemp(toNamespacedPath(cwdPrefix))
const cwd = join(dirname(cwdPrefix), basename(allocatedCwd))
const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])]
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
let spillRoot: string | undefined
@@ -183,6 +183,8 @@ export interface Scenario {
* test and the scenario needs an independent project location.
*/
workspaceParent?: string
/** Exact JSON-quoted cwd length forwarded to the harness for path-priced fixtures. */
workspaceCwdTextLength?: number
/**
* Optional final workspace preparation after the committed fixture is
* copied. Reserve this for paths that Git cannot represent portably; normal
@@ -1337,6 +1339,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
...existsSync(workspaceDir) ? { workspaceDir } : {},
...scenario.prepareWorkspace !== undefined ? { prepareWorkspace: scenario.prepareWorkspace } : {},
...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {},
...scenario.workspaceCwdTextLength === undefined ? {} : { workspaceCwdTextLength: scenario.workspaceCwdTextLength },
// A scenario passes its live profile patch; the launcher derives
// the sibling `*cordis.snapshot.yml` for replay.
...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {},
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import * as fsPromises from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { once } from 'node:events'
import { tmpdir } from 'node:os'
import { delimiter, join, relative, sep } from 'node:path'
import { basename, delimiter, dirname, join, relative, sep, toNamespacedPath } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { afterAll, describe, expect, it, vi, type TestContext } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
@@ -12,8 +13,35 @@ import { launchAcpTestAgent } from '../src/launcher.ts'
const fsControl = vi.hoisted(() => ({
cleanupFailure: undefined as Error | undefined,
spillAllocationFailure: undefined as { error: Error; allocated: string[] } | undefined,
legacyRealpathParent: undefined as string | undefined,
allocationAlias: undefined as { parent: string; alias: string; allocated: string[] } | undefined,
}))
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>()
return {
...actual,
toNamespacedPath(path: string): string {
const mapping = fsControl.allocationAlias
const native = mapping !== undefined && actual.dirname(path) === mapping.parent
? actual.join(mapping.alias, actual.basename(path))
: path
return actual.toNamespacedPath(native)
},
}
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
const realpathSync = Object.assign((...args: Parameters<typeof actual.realpathSync>) => {
const parent = fsControl.legacyRealpathParent
return parent !== undefined && args[0] === parent && args[1] === undefined
? parent
: actual.realpathSync(...args)
}, { native: actual.realpathSync.native })
return { ...actual, realpathSync }
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
@@ -21,7 +49,12 @@ vi.mock('node:fs/promises', async (importOriginal) => {
async mkdtemp(prefix: string): Promise<string> {
const failure = fsControl.spillAllocationFailure
if (prefix.endsWith('acp-snap-spill-') && failure !== undefined) throw failure.error
const mapping = fsControl.allocationAlias
if (mapping !== undefined && dirname(prefix) === mapping.parent) {
throw Object.assign(new Error('workspace allocation requires its native path'), { code: 'ENOENT' })
}
const path = await actual.mkdtemp(prefix)
if (mapping !== undefined && basename(prefix).startsWith('acp-snap-cwd-')) mapping.allocated.push(path)
failure?.allocated.push(path)
return path
},
@@ -683,6 +716,161 @@ describe('runScenario', () => {
expect(child.startsWith(`..${sep}`)).toBe(false)
})
it('fixes the serialized cwd length across different temporary parents and removes each generated directory', async () => {
const { fixtureFile } = await scenario({})
const parent = await mkdtemp(join(tmpdir(), 'acp-snap-path-length-'))
tempDirs.push(parent)
const longerParent = join(parent, 'a-longer-parent-directory')
await mkdir(longerParent)
const settled = await Promise.allSettled([parent, longerParent].map(workspaceParent => runScenario(
{ steps: boot },
{
agent: AGENT, mode: 'replay', fixtureFile, workspaceParent, workspaceCwdTextLength: 192,
prepareWorkspace: async (cwd) => {
expect(cwd).toBe(await realpath(cwd))
expect(JSON.stringify(cwd).length).toBe(192)
},
},
)))
const results = settled.map((result) => {
if (result.status === 'rejected') throw result.reason
return result.value
})
expect(new Set(results.map(result => result.cwd)).size).toBe(2)
for (const result of results) {
expect(JSON.stringify(result.cwd).length).toBe(192)
expect(result.cwd).toMatch(/acp-snap-cwd-[-a-zA-Z0-9]+$/)
await expect(readdir(result.cwd)).rejects.toMatchObject({ code: 'ENOENT' })
}
expect(await readdir(parent)).toEqual(['a-longer-parent-directory'])
})
it.each([1, 192.5, 2 ** 53, 2 ** 52])('rejects unusable serialized cwd length %s before allocating a workspace', async (workspaceCwdTextLength) => {
const { fixtureFile } = await scenario({})
const allocate = vi.spyOn(fsPromises, 'mkdtemp')
try {
await expect(runScenario(
{ steps: boot },
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceCwdTextLength },
)).rejects.toThrow('workspaceCwdTextLength must be a safe integer between')
expect(allocate).not.toHaveBeenCalled()
} finally {
allocate.mockRestore()
}
})
it.each([254, 255])('allocates and cleans up a padded cwd with a %s-byte basename', async (nameBytes) => {
const { fixtureFile } = await scenario({})
const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-name-bound-'))
tempDirs.push(workspaceParent)
const parent = await realpath(workspaceParent)
const workspaceCwdTextLength = JSON.stringify(join(parent, 'a'.repeat(nameBytes))).length
const stop = new Error('workspace inspected')
let allocated = ''
await expect(runScenario(
{ steps: boot },
{
agent: AGENT, mode: 'replay', fixtureFile, workspaceParent, workspaceCwdTextLength,
prepareWorkspace: async (cwd) => {
allocated = cwd
expect(Buffer.byteLength(basename(cwd))).toBe(nameBytes)
expect(JSON.stringify(cwd).length).toBe(workspaceCwdTextLength)
await readdir(cwd)
// Directory-name bounds are independent of subprocess cwd length limits.
throw stop
},
},
)).rejects.toBe(stop)
expect(allocated).not.toBe('')
await expect(readdir(allocated)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readdir(parent)).toEqual([])
})
it('rejects a padded cwd one byte beyond the basename limit before allocating a workspace', async () => {
const { fixtureFile } = await scenario({})
const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-name-overflow-'))
tempDirs.push(workspaceParent)
const parent = await realpath(workspaceParent)
const workspaceCwdTextLength = JSON.stringify(join(parent, 'a'.repeat(256))).length
const allocate = vi.spyOn(fsPromises, 'mkdtemp')
try {
await expect(runScenario(
{ steps: boot },
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceParent, workspaceCwdTextLength },
)).rejects.toThrow('workspaceCwdTextLength must be a safe integer between')
expect(allocate).not.toHaveBeenCalled()
} finally {
allocate.mockRestore()
}
expect(await readdir(parent)).toEqual([])
})
it('allocates through the native path while preserving the priced logical cwd and cleanup', async () => {
const { fixtureFile } = await scenario({})
const root = await mkdtemp(join(tmpdir(), 'acp-snap-allocation-alias-'))
tempDirs.push(root)
const parent = join(await realpath(root), 'parent')
const alias = join(await realpath(root), 'native-alias')
await mkdir(parent)
await symlink(parent, alias, 'junction')
// A real directory alias models a native allocation spelling without changing the request cwd.
const mapping = { parent, alias, allocated: [] as string[] }
fsControl.allocationAlias = mapping
try {
const result = await runScenario(
{ steps: boot },
{
agent: AGENT, mode: 'replay', fixtureFile, workspaceParent: parent, workspaceCwdTextLength: 192,
prepareWorkspace: async (cwd) => {
expect(dirname(cwd)).toBe(parent)
expect(cwd).toBe(await realpath(cwd))
expect(JSON.stringify(cwd).length).toBe(192)
},
},
)
expect(mapping.allocated).toEqual([toNamespacedPath(join(alias, basename(result.cwd)))])
expect(dirname(result.cwd)).toBe(parent)
expect(JSON.stringify(result.cwd).length).toBe(192)
await expect(readdir(result.cwd)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readdir(parent)).toEqual([])
expect(await readdir(alias)).toEqual([])
} finally {
fsControl.allocationAlias = undefined
}
})
it('prices the native long parent path when legacy resolution retains a short alias', async () => {
const { fixtureFile } = await scenario({})
const allocatedRoot = await mkdtemp(join(tmpdir(), 'acp-snap-native-parent-'))
tempDirs.push(allocatedRoot)
const root = await realpath(allocatedRoot)
const longParent = join(root, 'long-parent-directory')
const shortParent = join(root, 'PARENT~1')
await mkdir(longParent)
// The junction keeps allocation and subprocess I/O real while the one parent lookup models Windows 8.3 names.
await symlink(longParent, shortParent, 'junction')
fsControl.legacyRealpathParent = shortParent
try {
expect(realpathSync(shortParent)).toBe(shortParent)
expect(realpathSync.native(shortParent)).toBe(await realpath(longParent))
const result = await runScenario(
{ steps: boot },
{
agent: AGENT, mode: 'replay', fixtureFile, workspaceParent: shortParent, workspaceCwdTextLength: 192,
prepareWorkspace: async (cwd) => {
expect(cwd).toBe(await realpath(cwd))
expect(JSON.stringify(cwd).length).toBe(192)
},
},
)
expect(JSON.stringify(result.cwd).length).toBe(192)
expect(await readdir(longParent)).toEqual([])
await expect(readdir(result.cwd)).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
fsControl.legacyRealpathParent = undefined
}
})
it('promptAndCancel waits for the durable turn start, cancels, and settles the prompt', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
const result = await runScenario(
@@ -106,9 +106,11 @@ const REPLAY_SCENARIOS: Scenario[] = [
env: { DSH_PERMISSION_MODE: 'never' },
configPath: AGENT.configPath,
workspaceParent: tmpdir(),
workspaceCwdTextLength: 192,
pinsChildToolSchemas: [1],
pinsChildSystemPrompts: [1],
prepareWorkspace: (cwd) => {
expect(JSON.stringify(cwd).length).toBe(192)
writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime')
},
},
+8
View File
@@ -27,6 +27,7 @@ const controllerCases: readonly {
readonly name: string
readonly hasModelTurn: boolean
readonly configPath?: string
readonly workspaceCwdTextLength?: number
}[] = [
{ name: 'handshake', hasModelTurn: false },
{ name: 'reject-extra-dirs', hasModelTurn: false },
@@ -41,6 +42,13 @@ const controllerCases: readonly {
hasModelTurn: true,
configPath: join(corpusDir, 'image-compaction', 'cordis.yml'),
},
{
name: 'summary-overflow',
hasModelTurn: true,
configPath: join(corpusDir, 'summary-overflow', 'cordis.yml'),
// Pins the runtime-context token price recorded in summary-overflow/session.v4.jsonl.
workspaceCwdTextLength: 192,
},
] as const
function localScenarioSource(source: string | undefined): string | undefined {
@@ -0,0 +1,58 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek-api-key'
disabled: true
- id: acp
name: '@deepseek-ai/dsh-acp'
config:
provider: deepseek-official
model: deepseek-v4-flash-vision-exp
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
compression: none
- id: agent-instructions
name: '@deepseek-ai/dsh-agent-instructions'
config:
maxBytes: 65536
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
config:
personaPrefix: |
You are a coding assistant powered by the {{model}} model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek-official
name: DeepSeek
models:
- id: deepseek-v4-flash
inputModalities: [text]
- id: deepseek-v4-pro
inputModalities: [text]
- id: deepseek-v4-flash-vision-exp
inputModalities: [text, image]
contextWindow: 1000000
imageRequestTokens: 384
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
config:
headroomTokens: 0
maxTokens: 8192
retainTokens: 100
- insert:
- name: './summary-window.mjs'
+37
View File
@@ -0,0 +1,37 @@
- id: acp
name: '@deepseek-ai/dsh-acp'
config:
provider: deepseek-official
model: deepseek-v4-flash-vision-exp
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
compression: none
- id: agent-instructions
name: '@deepseek-ai/dsh-agent-instructions'
config:
maxBytes: 65536
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
config:
personaPrefix: |
You are a coding assistant powered by the {{model}} model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
config:
headroomTokens: 0
maxTokens: 8192
retainTokens: 100
- insert:
- name: './summary-window.mjs'
+67
View File
@@ -0,0 +1,67 @@
{
"steps": [
{
"op": "initialize"
},
{
"op": "newSession"
},
{
"op": "promptContent",
"content": [
{
"type": "text",
"text": "First group: inspect these three reference screenshots and acknowledge receipt."
},
{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"mimeType": "image/png"
},
{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"mimeType": "image/png"
},
{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"mimeType": "image/png"
}
]
},
{
"op": "promptContent",
"content": [
{
"type": "text",
"text": "Second group: inspect these three reference screenshots and acknowledge receipt."
},
{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"mimeType": "image/png"
},
{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"mimeType": "image/png"
},
{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"mimeType": "image/png"
}
]
},
{
"op": "promptContent",
"content": [
{
"type": "text",
"text": "Now reply with exactly the single word DONE."
}
]
}
]
}
@@ -0,0 +1,40 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783952000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"First group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}}
{"type":"session/title","data":{"title":"First group: inspect these three","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received the first three frames."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1792679887018,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1792679887019,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received the first three frames."}}},{"type":"chunk","time":1792679887019,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1792679887019,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Second group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Second group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received the next three frames."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1792679887081,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1792679887081,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received the next three frames."}}},{"type":"chunk","time":1792679887081,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1792679887081,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]}}
{"type":"turn/start","data":{"turn":3}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":3,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"assistant/attempt","data":{"turn":3,"step":1,"stream":[{"type":"chunk","time":1792679887096,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"CONTEXT_WINDOW_EXCEEDED","message":"screenshot history exceeds the authored context window"}}}}]}}
{"type":"compaction/start","data":{"compactionId":"{{id:1}}","turn":3}}
{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","summary":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"rawOutput":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":13},"shadowedSeqs":[8,9,13],"shadowedTokenCount":349,"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","maxTokens":8192,"usage":{"inputTokens":20,"outputTokens":16}}}
{"type":"user/message","data":{"content":[{"type":"text","text":"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.\n\n<compacted-summary>"},{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."},{"type":"text","text":"</compacted-summary>"}],"source":{"kind":"compact-checkpoint","compactionId":"{{id:1}}"},"role":"user","id":"{{message:8}}"},"sourceEventSeqs":[30,31,8,9,13],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":13}}
{"type":"compaction/end","data":{"compactionId":"{{id:1}}","turn":3}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1792679887109,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1792679887109,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792679887109,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1792679887109,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":3,"step":1}}
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}}
@@ -0,0 +1,16 @@
version: 1
scenario: summary-overflow
profile: acp
composition: summary-overflow
recording: authored
header:
class: summary-overflow
pin: true
systemPromptSource: image-compaction
toolSchemasSource: escalation-approved
permission: danger-full-access
input:
attachments:
- id: sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640
mediaType: image/png
data: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElFTkSuQmCC
@@ -0,0 +1,11 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Received the first three frames."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":1000000}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Received the next three frames."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":1000000}}}
{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":1000000}}}
{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,26 @@
/** Reject summaries containing both screenshot groups without consuming a replay entry. */
export const name = 'summary-window'
export const inject = ['llm']
/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local context. */
export function apply(ctx) {
ctx.on('llm/stream', async function* (options, next) {
const images = options.messages.flatMap(message => message.content)
.filter(block => block.type === 'image')
if (options.purpose === 'compaction' && images.length > 3) {
yield {
type: 'finish',
reason: { kind: 'error', failure: {
code: 'CONTEXT_WINDOW_EXCEEDED', message: 'summary screenshots exceed the authored context window',
} },
}
return
}
const hasCheckpoint = options.messages.some(message => message.source?.kind === 'compact-checkpoint')
if (options.purpose !== 'compaction' && hasCheckpoint
&& (images.length !== 3 || images.some(image => image.offloaded === true))) {
throw new Error('summary recovery must retain the second screenshot group')
}
yield* next()
})
}