Sfoglia il codice sorgente

fix(compaction): recover durable image offload in summary requests

creatixchu 1 settimana fa
parent
commit
debb4b9a9c
48 ha cambiato i file con 388 aggiunte e 112 eliminazioni
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
  3. 2 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 1 13
      docs/config-catalog.md
  6. 1 13
      docs/config-catalog.zh.md
  7. 2 2
      docs/event-producer-consumer.i18n.yaml
  8. 1 0
      docs/event-producer-consumer.md
  9. 1 0
      docs/event-producer-consumer.zh.md
  10. 2 2
      docs/module-graph.i18n.yaml
  11. 5 4
      docs/module-graph.md
  12. 5 4
      docs/module-graph.zh.md
  13. 2 2
      docs/subsystems/compaction.i18n.yaml
  14. 32 1
      docs/subsystems/compaction.md
  15. 32 1
      docs/subsystems/compaction.zh.md
  16. 2 2
      packages/compaction/command-compact/README.i18n.yaml
  17. 2 2
      packages/compaction/command-compact/README.md
  18. 2 2
      packages/compaction/command-compact/README.zh.md
  19. 2 2
      packages/compaction/command-compact/src/index.ts
  20. 2 2
      packages/compaction/command-compact/tests/command-compact.spec.ts
  21. 2 2
      packages/compaction/compaction-basic/README.i18n.yaml
  22. 2 0
      packages/compaction/compaction-basic/README.md
  23. 2 0
      packages/compaction/compaction-basic/README.zh.md
  24. 7 5
      packages/compaction/compaction-basic/src/index.ts
  25. 17 1
      packages/compaction/compaction-basic/src/region.ts
  26. 1 3
      packages/compaction/compaction-basic/src/summarizer.ts
  27. 2 2
      packages/compaction/compaction-image-offload/README.i18n.yaml
  28. 3 1
      packages/compaction/compaction-image-offload/README.md
  29. 3 1
      packages/compaction/compaction-image-offload/README.zh.md
  30. 7 4
      packages/compaction/compaction-image-offload/package.json
  31. 4 3
      packages/compaction/compaction-image-offload/src/image-offload.ts
  32. 14 13
      packages/compaction/compaction-image-offload/src/index.ts
  33. 132 2
      packages/compaction/compaction-image-offload/tests/image-offload.spec.ts
  34. 1 1
      packages/compaction/compaction-image-offload/tsconfig.json
  35. 2 2
      packages/compaction/compaction/README.i18n.yaml
  36. 2 0
      packages/compaction/compaction/README.md
  37. 2 0
      packages/compaction/compaction/README.zh.md
  38. 16 0
      packages/compaction/compaction/src/index.ts
  39. 8 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  40. 2 2
      packages/llm/llm-deepseek/src/serialize.ts
  41. 2 3
      packages/llm/llm/src/content.ts
  42. 2 3
      packages/llm/llm/src/error.ts
  43. 8 4
      packages/llm/llm/tests/content.spec.ts
  44. 15 4
      pnpm-lock.yaml
  45. 1 0
      scripts/gen-cordis-catalog.ts
  46. 3 0
      snapshots/acp/image-compaction/cordis.snapshot.yml
  47. 3 0
      snapshots/acp/image-compaction/cordis.yml
  48. 23 0
      snapshots/acp/image-compaction/summary-image-budget.mjs

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
-2026-09-10-image-offload-events.md: d9ade125733422cbb597669e4f5dce0d042b1d7c
-2026-09-10-image-offload-events.zh.md: 7d439fc71caadf409dc73182162b2c5cf6e439e4
+2026-09-10-image-offload-events.md: 0e2dbac4629232c578711c96fa24d8c4d8765f07
+2026-09-10-image-offload-events.zh.md: 7e8d104b58a9714abd30b5ceb143e54fb6af8f35

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md

@@ -12,6 +12,8 @@ A request can exceed an image-capable route's byte or image-count budget. Recomp
 
 `dsh-compaction-image-offload` owns recovery on the `agent/request-error` waterfall. An `IMAGE_OFFLOAD_REQUIRED` failure supplies `offloadImages`; the plugin selects that many oldest retained input-image occurrences in current surface order, appends one `image/offload` event, and returns `retry`. Assistant output images are excluded. If no retained input image remains, the plugin delegates. Other failures do not enter this recovery. Offload does not consume the provider retry budget or emit `llm/retry`.
 
+Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preserves the full `LlmFailure`, checks cancellation and selection stability, and dispatches synchronous `compaction/summary-error` with the selected event seqs. The same image-offload plugin records omissions only within that selection. Synchronous recovery keeps the stability check and decision adjacent. The backend then re-derives input and pricing, including the shrink baseline. Each retry requires additional omitted occurrences; exhaustion delegates the failure. A later summary failure or cancellation preserves any recorded omissions, so command errors do not claim the conversation is unchanged.
+
 The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
 
 The event has no `surfaceOp`. It neither creates a message nor replaces a message node. Session validates all targets before commit and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 `dsh-compaction-image-offload` 在 `agent/request-error` waterfall 上负责恢复。`IMAGE_OFFLOAD_REQUIRED` 失败提供 `offloadImages`,插件按当前 surface 顺序选择该数量最旧且仍保留的输入图片出现位置,追加一个 `image/offload` 事件,再返回 `retry`。Assistant 输出图片不参与选择。没有保留的输入图片时,插件委托后续处理。其他失败不进入这项恢复。图片省略不消耗提供方重试预算,也不产生 `llm/retry`。
 
+摘要请求不经过 agent 错误 waterfall。`dsh-compaction-basic` 保留完整的 `LlmFailure`,检查取消和选区稳定性,再携带所选事件序号触发同步的 `compaction/summary-error`。同一个图片省略插件只在选区内记录省略。同步恢复使稳定性检查与决策相邻执行。后端随后重新派生输入并计价,包括判断摘要是否缩短输入的基准。每次重试都必须继续省略图片,无图可省略时委派失败。后续摘要失败或取消仍保留已经记录的省略,因此命令错误不声称会话未变。
+
 事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
 
 事件不带 `surfaceOp`,不创建消息,也不替换消息节点。Session 在提交前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 37c6e6eb60b025988c932b008fb82a1379fd152b
-config-catalog.zh.md: 8dbc29a64c5a0d7f0fded0630897e006a1362f03
+config-catalog.md: 5e327d8531385267c646b3f9d376187a68ab7326
+config-catalog.zh.md: c5e605e0fa74c561340c9bff80162bde4af49a30

+ 1 - 13
docs/config-catalog.md

@@ -484,19 +484,6 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig {
 
 Source: [`packages/compaction/compaction-basic/src/types.ts:38`](../packages/compaction/compaction-basic/src/types.ts)
 
-<a id="deepseek-aidsh-compaction-image-offload"></a>
-
-## `@deepseek-ai/dsh-compaction-image-offload`
-
-Requires: `agents`
-
-```ts config-catalog
-/** The executor has no configuration; image-capable routes own their budgets. */
-export type Config = Readonly<Record<string, never>>
-```
-
-Source: [`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts)
-
 <a id="deepseek-aidsh-compaction-tool-result-pruner"></a>
 
 ## `@deepseek-ai/dsh-compaction-tool-result-pruner`
@@ -3509,6 +3496,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts))
+- `@deepseek-ai/dsh-compaction-image-offload` — requires `agents` ([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts))
 - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts))
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))
 - `@deepseek-ai/dsh-experimental-client-ui-agent-team` ([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts))

+ 1 - 13
docs/config-catalog.zh.md

@@ -486,19 +486,6 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig {
 
 来源:[`packages/compaction/compaction-basic/src/types.ts:38`](../packages/compaction/compaction-basic/src/types.ts)
 
-<a id="deepseek-aidsh-compaction-image-offload"></a>
-
-## `@deepseek-ai/dsh-compaction-image-offload`
-
-需要:`agents`
-
-```ts config-catalog
-/** The executor has no configuration; image-capable routes own their budgets. */
-export type Config = Readonly<Record<string, never>>
-```
-
-来源:[`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts)
-
 <a id="deepseek-aidsh-compaction-tool-result-pruner"></a>
 
 ## `@deepseek-ai/dsh-compaction-tool-result-pruner`
@@ -3511,6 +3498,7 @@ export interface Config {
 - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — 需要 `commands` · `goals`([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts))
+- `@deepseek-ai/dsh-compaction-image-offload`,需要 `agents`([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts))
 - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts))
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))
 - `@deepseek-ai/dsh-experimental-client-ui-agent-team`([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 280a68df728374f2d65634b5711ae0f025d8a4c6
-event-producer-consumer.zh.md: aac8158bad38eca367170f8dc455c26fca649656
+event-producer-consumer.md: 7f27d072254804e586414079b85a4078059f86a8
+event-producer-consumer.zh.md: 26083843ac8b0b0f314cf5a08139107661c963ee

+ 1 - 0
docs/event-producer-consumer.md

@@ -30,6 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |

+ 1 - 0
docs/event-producer-consumer.zh.md

@@ -32,6 +32,7 @@
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: bcb1e3cd590b5466220c7f8f32bfd11bd884362f
-module-graph.zh.md: 815e91b89c79abfed97fdc0e902740459e14793b
+module-graph.md: 464614c2f00002cd66b1acde8bb6c9f41175c72e
+module-graph.zh.md: c6a401f534700f22deca036bc0df29af670bf957

+ 5 - 4
docs/module-graph.md

@@ -560,9 +560,6 @@ flowchart TD
   pkg_api_workspace_controller --> pkg_storage_domain
   pkg_api_workspace_controller --> pkg_typert_protocol
   pkg_api_workspace_controller --> pkg_workspace
-  pkg_compaction_image_offload --> pkg_agent
-  pkg_compaction_image_offload --> pkg_llm
-  pkg_compaction_image_offload --> pkg_session
   pkg_file_reference --> pkg_agent
   pkg_time_context --> pkg_agent
   pkg_time_context --> pkg_invariants
@@ -786,6 +783,10 @@ flowchart TD
   pkg_hooks_codex --> pkg_tools
   pkg_command_compact --> pkg_commands
   pkg_command_compact --> pkg_compaction
+  pkg_compaction_image_offload --> pkg_agent
+  pkg_compaction_image_offload --> pkg_compaction
+  pkg_compaction_image_offload --> pkg_llm
+  pkg_compaction_image_offload --> pkg_session
   pkg_agent_instructions --> pkg_agent
   pkg_agent_instructions --> pkg_fs
   pkg_agent_instructions --> pkg_home_paths
@@ -1353,7 +1354,6 @@ flowchart TD
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
-| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
 | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1399,6 +1399,7 @@ flowchart TD
 | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
 | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) |
+| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |

+ 5 - 4
docs/module-graph.zh.md

@@ -562,9 +562,6 @@ flowchart TD
   pkg_api_workspace_controller --> pkg_storage_domain
   pkg_api_workspace_controller --> pkg_typert_protocol
   pkg_api_workspace_controller --> pkg_workspace
-  pkg_compaction_image_offload --> pkg_agent
-  pkg_compaction_image_offload --> pkg_llm
-  pkg_compaction_image_offload --> pkg_session
   pkg_file_reference --> pkg_agent
   pkg_time_context --> pkg_agent
   pkg_time_context --> pkg_invariants
@@ -788,6 +785,10 @@ flowchart TD
   pkg_hooks_codex --> pkg_tools
   pkg_command_compact --> pkg_commands
   pkg_command_compact --> pkg_compaction
+  pkg_compaction_image_offload --> pkg_agent
+  pkg_compaction_image_offload --> pkg_compaction
+  pkg_compaction_image_offload --> pkg_llm
+  pkg_compaction_image_offload --> pkg_session
   pkg_agent_instructions --> pkg_agent
   pkg_agent_instructions --> pkg_fs
   pkg_agent_instructions --> pkg_home_paths
@@ -1355,7 +1356,6 @@ flowchart TD
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
-| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
 | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1401,6 +1401,7 @@ flowchart TD
 | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
 | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) |
+| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/subsystems/compaction.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/compaction.md
-compaction.md: 6a8743feac0481626f171e3926c7f924508db54a
-compaction.zh.md: 1b8d2a2d1e47067583593ca3fae039bc78ed06f9
+compaction.md: 33af5e6a4a4da4f18c93e49c03ef65764efc5c57
+compaction.zh.md: 2dffc032b116fd2c7c6963a9009e02cb612c763b

+ 32 - 1
docs/subsystems/compaction.md

@@ -81,7 +81,7 @@ type ManualCompactionErrorCode =
   | 'persistence'
 ```
 
-`changed` and `summary` leave the conversation surface unchanged but still close and persist the failed attempt in the log. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup.
+`changed` and `summary` close and persist the failed attempt without a summary replacement; image omissions recorded during recovery remain effective. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup.
 
 Pressure compaction runs at the `agent/pre-step` waterfall before request derivation. Once pressure or canonical overflow qualifies, compaction-basic invokes optional [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compaction-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
 
@@ -235,4 +235,35 @@ pruneSession(session: Session): PruneResult
 Types: [ContentBlock](llm-streaming.md) · [Session](session.md)
 
 Source: [`packages/compaction/compaction-tool-result-pruner/src/index.ts`](../../packages/compaction/compaction-tool-result-pruner/src/index.ts)
+
+<a id="compaction-events"></a>
+
+### `compaction/*` events
+
+<a id="compactionsummary-error--waterfall"></a>
+
+#### `compaction/summary-error` — waterfall
+
+Recover a failed summary request by synchronously recording a durable change to its selected input. Return true only after making progress; the provider re-derives and re-prices the selection before retrying. Call next() when the failure cannot be recovered. Decisions survive a later summary failure or cancellation.
+
+```ts cordis-catalog
+/**
+ * Recover a failed summary request by synchronously recording a durable
+ * change to its selected input. Return true only after making progress;
+ * the provider re-derives and re-prices the selection before retrying.
+ * Call next() when the failure cannot be recovered. Decisions survive a
+ * later summary failure or cancellation.
+ * @param payload.session - session containing the selected input.
+ * @param payload.sourceEventSeqs - selected message events in request order.
+ * @param payload.error - failure thrown by the summarizer.
+ * @param payload.signal - optional compaction cancellation signal.
+ * @param next - delegate to the next recovery listener.
+ * @mode waterfall
+ */
+'compaction/summary-error'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean
+```
+
+Types: [Session](session.md) · [SessionSeq](session.md)
+
+Source: [`packages/compaction/compaction/src/index.ts`](../../packages/compaction/compaction/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 32 - 1
docs/subsystems/compaction.zh.md

@@ -81,7 +81,7 @@ type ManualCompactionErrorCode =
   | 'persistence'
 ```
 
-`changed` 和 `summary` 保持会话表层不变,但仍会闭合失败尝试并将其持久化到日志。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。
+`changed` 和 `summary` 闭合失败尝试并将其持久化到日志,不写入摘要替换;恢复过程中记录的图片省略仍然有效。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。
 
 压力压缩在 `agent/pre-step` waterfall(瀑布式事件)中运行,先于请求推导。一旦压力或规范化溢出满足条件,compaction-basic 会在选择范围前调用可选的 [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.zh.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compaction-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。
 
@@ -235,4 +235,35 @@ pruneSession(session: Session): PruneResult
 Types: [ContentBlock](llm-streaming.zh.md) · [Session](session.zh.md)
 
 Source: [`packages/compaction/compaction-tool-result-pruner/src/index.ts`](../../packages/compaction/compaction-tool-result-pruner/src/index.ts)
+
+<a id="compaction-events"></a>
+
+### `compaction/*` events
+
+<a id="compactionsummary-error--waterfall"></a>
+
+#### `compaction/summary-error` — waterfall
+
+Recover a failed summary request by synchronously recording a durable change to its selected input. Return true only after making progress; the provider re-derives and re-prices the selection before retrying. Call next() when the failure cannot be recovered. Decisions survive a later summary failure or cancellation.
+
+```ts cordis-catalog
+/**
+ * Recover a failed summary request by synchronously recording a durable
+ * change to its selected input. Return true only after making progress;
+ * the provider re-derives and re-prices the selection before retrying.
+ * Call next() when the failure cannot be recovered. Decisions survive a
+ * later summary failure or cancellation.
+ * @param payload.session - session containing the selected input.
+ * @param payload.sourceEventSeqs - selected message events in request order.
+ * @param payload.error - failure thrown by the summarizer.
+ * @param payload.signal - optional compaction cancellation signal.
+ * @param next - delegate to the next recovery listener.
+ * @mode waterfall
+ */
+'compaction/summary-error'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean
+```
+
+Types: [Session](session.zh.md) · [SessionSeq](session.zh.md)
+
+Source: [`packages/compaction/compaction/src/index.ts`](../../packages/compaction/compaction/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 2 - 2
packages/compaction/command-compact/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/compaction/command-compact/README.md
-README.md: b8881234a3892ac566e41f28fbccbbf9ffa7fc3d
-README.zh.md: 821017a1a89bc7ffd07b91313f9bf56b42f143c2
+README.md: 4c9a550febd3cef13fea82c61f9bf96f65a17b64
+README.zh.md: ad31604e9e37c3b676da9b6497cab660e90bc0e4

+ 2 - 2
packages/compaction/command-compact/README.md

@@ -42,8 +42,8 @@ The command turns each expected failure into a stable message you can show direc
 | Situation | Message you see |
 |---|---|
 | Compaction already running, or the agent is mid-turn | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
-| The history changed while condensing | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
-| No useful summary could be produced | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
+| The history changed while condensing | `The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.` |
+| No useful summary could be produced | `Compaction could not produce a useful summary. The attempt is recorded in the session log.` |
 | Condensation did not finish cleanly | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
 | The conversation could not be saved | `Compaction finished, but the session could not be saved.` |
 

+ 2 - 2
packages/compaction/command-compact/README.zh.md

@@ -42,8 +42,8 @@ kind: "package-reference"
 | 情形 | 你看到的消息 |
 |---|---|
 | 压缩已在运行,或 agent 正在轮次中 | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
-| 压缩过程中历史发生了变化 | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
-| 无法产生有用的摘要 | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
+| 压缩过程中历史发生了变化 | `The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.` |
+| 无法产生有用的摘要 | `Compaction could not produce a useful summary. The attempt is recorded in the session log.` |
 | 压缩未干净地完成 | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
 | 会话无法保存 | `Compaction finished, but the session could not be saved.` |
 

+ 2 - 2
packages/compaction/command-compact/src/index.ts

@@ -33,12 +33,12 @@ function expectedFailure(error: ManualCompactionError): CommandResult {
     case 'changed':
       return {
         kind: 'error',
-        text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
+        text: 'The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.',
       }
     case 'summary':
       return {
         kind: 'error',
-        text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
+        text: 'Compaction could not produce a useful summary. The attempt is recorded in the session log.',
       }
     case 'commit':
       return {

+ 2 - 2
packages/compaction/command-compact/tests/command-compact.spec.ts

@@ -208,8 +208,8 @@ describe('/compact human command', () => {
   it.each([
     ['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
     ['cancelled', 'Compaction cancelled.'],
-    ['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
-    ['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
+    ['changed', 'The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.'],
+    ['summary', 'Compaction could not produce a useful summary. The attempt is recorded in the session log.'],
     ['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],
     ['persistence', 'Compaction finished, but the session could not be saved.'],
   ] as const)('maps expected %s failures to direct errors', async (code, text) => {

+ 2 - 2
packages/compaction/compaction-basic/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/compaction/compaction-basic/README.md
-README.md: 6f2c416f8c1adc87f04bbceb6a2ea1ba81fde2aa
-README.zh.md: a830a83683c9aa87f974d090355a70570d785e79
+README.md: 68d5a321e04a90bfb726392d1fb179612f6e0626
+README.zh.md: f941cac74053a680cad859b68c7836e181065bc9

+ 2 - 0
packages/compaction/compaction-basic/README.md

@@ -119,6 +119,8 @@ 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.
+
 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.
 
 ### Config resolution

+ 2 - 0
packages/compaction/compaction-basic/README.zh.md

@@ -119,6 +119,8 @@ kind: "package-reference"
 
 ### 区域事务
 
+摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。
+
 事务验证表层范围与持久锁,追加 `compaction/start`,通过钩子生成摘要,重新验证稳定性(自动调用要求整个表层、手动调用只要求所选范围),拒绝不缩小源内容的摘要,追加 `compaction/summary` 与替换 `user/message`,并恰好进行一次 `compaction/end` 尝试。活动的未匹配 start 是持久锁:位于较新 `session/end-seed` 之前的未匹配标记是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。闭合失败会有意留下阻塞性的未匹配标记。完成清理与持久化后,取消仍具有最终决定权。
 
 ### 配置解析

+ 7 - 5
packages/compaction/compaction-basic/src/index.ts

@@ -8,7 +8,6 @@ import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { CompactionEngine, ManualCompactionError } from '@deepseek-ai/dsh-compaction'
 import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compaction'
-import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
 import type { Session, SessionSeq } from '@deepseek-ai/dsh-session'
 import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
 import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
@@ -46,9 +45,6 @@ export type {
   ResolvedTargetPolicy,
 } from './types.ts'
 
-/** The region transaction's view of this service's dynamically dispatched summarizer. */
-type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise<SummaryResult>
-
 /** Resolve the exact provider/model durably routed for the latest request. */
 function routedTarget(
   session: Session,
@@ -421,10 +417,16 @@ export class BasicCompactionEngine extends CompactionEngine {
   }
 
   /** Bind the effective token meter and dynamically dispatched summarizer hook. */
-  private regionDependencies(): { meter: TokenMeter; summarize: RegionSummarize } {
+  private regionDependencies(): Parameters<typeof compactSurfaceRegion>[0] {
     return {
       meter: this.ctx.tokenMeter,
       summarize: (input, owner, abort) => this.summarize(input, owner, abort),
+      recover: (error, agent, sourceEventSeqs, signal) => this.ctx.waterfall('compaction/summary-error', {
+        session: agent.session,
+        sourceEventSeqs,
+        error,
+        ...signal === undefined ? {} : { signal },
+      }, () => false),
     }
   }
 }

+ 17 - 1
packages/compaction/compaction-basic/src/region.ts

@@ -27,6 +27,7 @@ import type { SummarizationInput, SummaryResult } from './summarizer.ts'
 interface RegionDependencies {
   readonly meter: TokenMeter
   summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
+  recover(error: unknown, agent: Agent, sourceEventSeqs: readonly SessionSeq[], signal?: AbortSignal): boolean
 }
 
 /** One validated inclusive span of current surface positions. */
@@ -225,6 +226,7 @@ export async function compactSurfaceRegion(
       agent,
       compactionId,
       options.sourceCommandId,
+      assertStable,
       signal,
     )
     if (options.owner === null) signal?.throwIfAborted()
@@ -388,9 +390,23 @@ async function summarizeCompaction(
   agent: Agent,
   compactionId: CompactionResult['compactionId'],
   sourceCommandId: CommandId | undefined,
+  assertStable: StabilityCheck,
   signal?: AbortSignal,
 ): Promise<SummarizedCompaction> {
-  const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
+  let summaryResult: SummaryResult
+  for (;;) {
+    signal?.throwIfAborted()
+    try {
+      summaryResult = await dependencies.summarize(prepared.input, agent, signal)
+      break
+    } catch (error: unknown) {
+      signal?.throwIfAborted()
+      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))
+    }
+  }
   const checkpointMessage = createUserMessage({
     content: frameSummary(summaryResult.summary),
     source: compactCheckpointSource(compactionId, sourceCommandId),

+ 1 - 3
packages/compaction/compaction-basic/src/summarizer.ts

@@ -196,9 +196,7 @@ function finishError(finish: FinishReason): Error | undefined {
   switch (finish.kind) {
     case 'error':
     case 'aborted': {
-      const error = new Error(finish.failure.message) as Error & { code?: string }
-      error.code = finish.failure.code
-      return error
+      return new LlmError(finish.failure.message, finish.failure.code, finish.failure)
     }
     case 'max-tokens': {
       const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }

+ 2 - 2
packages/compaction/compaction-image-offload/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md
-README.md: a34b8c43d0c1de2afa9d4bd4c9638c75bed98f51
-README.zh.md: 9d6c309eed697fcccde043fd87447bf1499410ec
+README.md: ed22ccfe8817a70cf05d2f3ff0d54bc779702f4f
+README.zh.md: c5830133533a44ec3c3cd36cab1593cda476177a

+ 3 - 1
packages/compaction/compaction-image-offload/README.md

@@ -35,7 +35,7 @@ Mount this plugin in every composition that runs the agent loop with an image-ca
 
 ### What you can observe
 
-Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. The retried request follows a fresh `request/header` identifying a new message series.
+Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. An agent retry follows a fresh `request/header` identifying a new message series. Summary retries remain inside the same compaction bracket.
 
 ### Failures and recovery
 
@@ -51,6 +51,8 @@ The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadIma
 
 The recovery listener owns selection and retry policy. It reads Session's projected messages, selects the oldest retained input images in request order, and commits the complete selection in one event. Session validates and applies those exact references during append and replay; its shared derivation supplies requests, compaction, and later message rewrites. Token measurement folds the same selections without replacement shadow prices.
 
+Summary failures use the synchronous `compaction/summary-error` waterfall. The plugin selects only the supplied summary region and returns true after recording an omission. The compaction backend re-derives and re-prices that region before retrying; each retry omits additional retained occurrences, so recovery ends when none remain. Cancellation and unrelated selection changes reject the summary. Recorded omissions survive later failure or cancellation.
+
 No runtime invariant companion is published: Session rejects invalid or repeated image references before commit, and this executor retains no separate mutable offload state.
 
 </details>

+ 3 - 1
packages/compaction/compaction-image-offload/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-reference"
 
 ### 你可以观察到什么
 
-每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。重试请求前会追加新的 `request/header`,标明新的消息序列。
+每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。agent 重试前会追加新的 `request/header`,标明新的消息序列。摘要重试保留在同一对压缩标记内。
 
 ### 失败与恢复
 
@@ -51,6 +51,8 @@ kind: "package-reference"
 
 恢复监听器负责选图和重试策略。它读取 Session 派生的消息,按请求顺序选取最早的保留输入图片,并用一条事件提交本次选择。Session 在追加和回放时校验并应用这些确切引用,共享派生结果供请求、压缩(compaction)和后续消息改写使用。token 测量应用同一份选择,不依赖替换影子价格。
 
+摘要失败使用同步的 `compaction/summary-error` waterfall。插件只在传入的摘要选区内选图,记录省略后返回 true。压缩后端重新派生该选区并计价,然后重试。每次重试都会继续省略保留的图片,没有可省略的位置时结束恢复。取消或无关的选区变更会使摘要失败。已经记录的省略在后续失败或取消后仍然有效。
+
 本包不发布运行时 invariant 伴生插件:Session 在提交前拒绝无效或重复省略的图片引用,执行器不维护独立可变的省略状态。
 
 </details>

+ 7 - 4
packages/compaction/compaction-image-offload/package.json

@@ -28,19 +28,22 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^"
   },
-  "dependencies": {
-    "@deepseek-ai/schemastery": "workspace:^"
-  },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-compaction": "workspace:^",
+    "@deepseek-ai/dsh-compaction-basic": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-token-meter": "workspace:^"
   }
 }

+ 4 - 3
packages/compaction/compaction-image-offload/src/image-offload.ts

@@ -1,19 +1,20 @@
 /** Select and log permanent image omissions in current model-request order. */
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import type { ImageOffloadTarget, Session } from '@deepseek-ai/dsh-session'
+import type { ImageOffloadTarget, Session, SessionSeq } from '@deepseek-ai/dsh-session'
 
 /**
  * Record one decision omitting the oldest retained input-image occurrences.
  * Assistant nodes carry model output and are excluded. Image indexes count
  * every occurrence, including previously offloaded ones, within each message.
  * @param session - session whose next request applies the decision.
+ * @param sourceEventSeqs - input message events in the failed request's order.
  * @param count - additional retained occurrences the adapter needs omitted.
  * @returns whether any occurrence remained to offload.
  */
-export function offloadOldestImages(session: Session, count: number): boolean {
+export function offloadOldestImages(session: Session, sourceEventSeqs: readonly SessionSeq[], count: number): boolean {
   const targets: ImageOffloadTarget[] = []
-  for (const seq of session.surface.nodes) {
+  for (const seq of sourceEventSeqs) {
     if (count === 0) break
     // oxlint-disable-next-line typescript/no-non-null-assertion -- current surface nodes index the durable log
     const event = session.eventAt(seq)!

+ 14 - 13
packages/compaction/compaction-image-offload/src/index.ts

@@ -2,37 +2,38 @@
  * Image offload executor for the compaction seam. When an image-capable route
  * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one
  * `image/offload` decision selecting the oldest retained input occurrences
- * and retries the step on the `agent/request-error` waterfall. Every route
+ * and retries through the agent or compaction summary error waterfall. Every route
  * sends placeholder text for those occurrences in subsequent requests.
  *
  * @module @deepseek-ai/dsh-compaction-image-offload
  */
 
 import type { Context } from '@deepseek-ai/cordis'
-import z from '@deepseek-ai/schemastery'
+import type {} from '@deepseek-ai/dsh-compaction'
 import type { RequestErrorAction } from '@deepseek-ai/dsh-agent'
-import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm'
+import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
 import { offloadOldestImages } from './image-offload.ts'
 
 export const name = 'compaction-image-offload'
 export const inject = ['agents']
 
-/** The executor has no configuration; image-capable routes own their budgets. */
-export type Config = Readonly<Record<string, never>>
-
-/** Runtime schema for {@link Config}. */
-export const Config = z.object({}) as unknown as z<Config>
-
 /**
- * Mount the recovery listener.
+ * Mount agent and summary recovery listeners without configuration.
  * @param ctx - the plugin context.
- * @param _config - no configuration.
  */
-export function apply(ctx: Context, _config: Config = {}): void {
+export function apply(ctx: Context): void {
   ctx.on('agent/request-error', ({ agent, failure }, next): Promise<RequestErrorAction> => {
     if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next()
     // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event.
-    if (!offloadOldestImages(agent.session, failure.offloadImages)) return next()
+    if (!offloadOldestImages(agent.session, agent.session.surface.nodes, failure.offloadImages)) return next()
     return Promise.resolve<RequestErrorAction>({ kind: 'retry' })
   })
+  ctx.on('compaction/summary-error', ({ session, sourceEventSeqs, error, signal }, next) => {
+    if (!(error instanceof LlmError)
+      || error.code !== IMAGE_OFFLOAD_REQUIRED_CODE
+      || error.failure.offloadImages === undefined) return next()
+    signal?.throwIfAborted()
+    if (!offloadOldestImages(session, sourceEventSeqs, error.failure.offloadImages)) return next()
+    return true
+  })
 }

+ 132 - 2
packages/compaction/compaction-image-offload/tests/image-offload.spec.ts

@@ -6,6 +6,11 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic'
+import TokenMeter from '@deepseek-ai/dsh-token-meter'
+import { ImageVariantId } from '@deepseek-ai/dsh-attachment'
+import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -18,13 +23,25 @@ type ScriptEntry = StreamChunk[] | (() => never)
 /** Replies one scripted stream per request and declares no retry policy. */
 class ScriptedAdapter extends LlmAdapter {
   readonly requests: GenerateOptions[] = []
+  serializeSummary = false
 
-  constructor(private readonly script: ScriptEntry[]) {
+  constructor(readonly script: ScriptEntry[]) {
     super()
   }
 
   async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
     this.requests.push(options)
+    if (this.serializeSummary && options.purpose === 'compaction') {
+      await serializeRequestWithImages(options, {
+        representation: { kind: 'base64' },
+        requestImages: new Map([image('first').attachment].map(ref => [ref.attachmentId, {
+          variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), attachment: ref,
+          data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes,
+          width: ref.width, height: ref.height, depth: 'uchar', space: 'srgb', hasAlpha: false,
+        }])),
+        maxRequestImageBytes: 1,
+      })
+    }
     const entry = this.script.shift()
     if (entry === undefined) throw new Error('script exhausted')
     if (typeof entry === 'function') entry()
@@ -50,7 +67,7 @@ async function harness(adapter: ScriptedAdapter): Promise<Context> {
   const ctx = new Context()
   contexts.push(ctx)
   await mountAgentLoopTestDependencies(ctx)
-  await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject }))
+  await ctx.plugin(offload)
   await ctx.plugin(AgentLoop, { agents: [] })
   ctx.llm.registerAdapter(['mock'], adapter)
   return ctx
@@ -91,6 +108,119 @@ function decisions(session: Session) {
   return session.snapshotEvents().filter(event => event.type === 'image/offload')
 }
 
+async function summaryHarness(script: ScriptEntry[]) {
+  const adapter = new ScriptedAdapter(script)
+  const ctx = await harness(adapter)
+  await ctx.plugin(TokenMeter)
+  const compact = new BasicCompactionEngine(ctx, { auto: false, summarizationProvider: 'mock', summarizationModel: 'summary' })
+  const agent = await ctx.agentLoop.create(SessionId('summary-offload'), { provider: 'mock', model: 'mock' })
+  return { ctx, compact, agent, adapter }
+}
+
+async function seedImages(agent: Agent, names: string[]) {
+  agent.followup(createUserMessage({
+    content: [{ type: 'text', text: 'conversation details '.repeat(400) }, ...names.map(image)],
+    source: { kind: 'user' },
+  }))
+  await agent.whenIdle()
+  const nodes = agent.session.surface.nodes
+  return { start: nodes.at(-2)!, end: nodes.at(-1)! }
+}
+
+describe('summary image offload', () => {
+  it.each([new Error('summary failed'), new LlmError('no count', IMAGE_OFFLOAD_REQUIRED_CODE)])('delegates unhandled summary errors: %s', async (error) => {
+    const { ctx, agent } = await summaryHarness([])
+    expect(ctx.waterfall('compaction/summary-error', { session: agent.session, sourceEventSeqs: [], error }, () => false)).toBe(false)
+    expect(decisions(agent.session)).toEqual([])
+  })
+
+  it('recovers the real summary serializer with a tighter image budget and fresh pricing', async () => {
+    const { compact, agent, adapter } = await summaryHarness([textResponse('answer'), textResponse('checkpoint')])
+    const span = await seedImages(agent, ['first', 'second'])
+    adapter.serializeSummary = true
+    const result = await compact.compactNow(agent, new AbortController().signal)
+    expect(result).not.toBeNull()
+    expect(adapter.requests.map(offloadedNames)).toEqual([[], [], ['first', 'second']])
+    expect(adapter.requests.slice(1).map(request => request.model)).toEqual(['summary', 'summary'])
+    const events = agent.session.snapshotEvents()
+    const types = events.map(event => event.type)
+    expect(types.filter(type => type === 'compaction/start')).toHaveLength(1)
+    expect(types.filter(type => type === 'compaction/end')).toHaveLength(1)
+    const [decision] = decisions(agent.session)
+    expect(decision?.data.targets).toEqual([{ seq: span.start, imageIndexes: [0, 1] }])
+    expect(decision!.seq).toBeLessThan(types.indexOf('compaction/summary'))
+    expect(events[span.start]).not.toHaveProperty('data.content.1.offloaded')
+    expect(types).not.toContain('compaction/prune')
+    expect(types).not.toContain('llm/retry')
+  })
+
+  it('offloads only the selected summary span and stops after exhausting its images', async () => {
+    const { compact, agent, adapter } = await summaryHarness([
+      textResponse('before'), textResponse('selected'), textResponse('after'),
+      offloadRequired(1), offloadRequired(1), offloadRequired(1),
+    ])
+    await seedImages(agent, ['outside-before'])
+    const selected = await seedImages(agent, ['first', 'second'])
+    await seedImages(agent, ['outside-after'])
+    agent.session.append('turn/start', { turn: 4 })
+    await expect(compact.compactRegion(selected.start, selected.end, agent)).rejects.toMatchObject({ code: IMAGE_OFFLOAD_REQUIRED_CODE })
+    expect(adapter.requests.slice(3).map(offloadedNames)).toEqual([[], ['first'], ['first', 'second']])
+    expect(decisions(agent.session).map(event => event.data.targets)).toEqual([
+      [{ seq: selected.start, imageIndexes: [0] }], [{ seq: selected.start, imageIndexes: [1] }],
+    ])
+    expect(agent.session.surface.replaceGeneration).toBe(0)
+    expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'compaction/end', data: { error: expect.any(String) } })
+  })
+
+  it('preserves omission when a subsequent summary failure is terminal', async () => {
+    const { compact, agent, adapter } = await summaryHarness([
+      textResponse('answer'), offloadRequired(1), () => { throw new LlmError('provider outage', 'SERVER') },
+    ])
+    await seedImages(agent, ['first'])
+    await expect(compact.compactNow(agent, new AbortController().signal)).rejects.toMatchObject({ code: 'summary', cause: { code: 'SERVER' } })
+    expect(adapter.requests).toHaveLength(3)
+    expect(decisions(agent.session)).toHaveLength(1)
+    expect(agent.session.surface.replaceGeneration).toBe(0)
+  })
+
+  it('does not record a decision after cancellation during a failed summary', async () => {
+    const controller = new AbortController()
+    const reason = new Error('cancel summary')
+    const { compact, agent, adapter } = await summaryHarness([
+      textResponse('answer'), () => { controller.abort(reason); return offloadRequired(1)() },
+    ])
+    await seedImages(agent, ['first'])
+    await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
+    expect(adapter.requests).toHaveLength(2)
+    expect(decisions(agent.session)).toEqual([])
+  })
+
+  it('does not retry when cancellation follows a durable omission', async () => {
+    const { ctx, compact, agent, adapter } = await summaryHarness([textResponse('answer'), offloadRequired(1)])
+    await seedImages(agent, ['first'])
+    const controller = new AbortController()
+    const reason = new Error('cancel after offload')
+    ctx.on('session/event', (_session, event) => { if (event.type === 'image/offload') controller.abort(reason) })
+    await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
+    expect(adapter.requests).toHaveLength(2)
+    expect(decisions(agent.session)).toHaveLength(1)
+  })
+
+  it('rejects a concurrently changed selection before recording omission', async () => {
+    const { compact, agent, adapter } = await summaryHarness([textResponse('answer')])
+    const selected = await seedImages(agent, ['first'])
+    adapter.script.push(() => {
+      agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } }), {
+        surfaceOp: { op: 'replace', startSeq: selected.start, endSeq: selected.end },
+        sourceEventSeqs: [selected.start, selected.end],
+      })
+      return offloadRequired(1)()
+    })
+    await expect(compact.compactNow(agent, new AbortController().signal)).rejects.toMatchObject({ code: 'changed' })
+    expect(decisions(agent.session)).toEqual([])
+  })
+})
+
 describe('compaction-image-offload', () => {
   it('logs one exact image selection and retries without replacing the message', async () => {
     const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')])

+ 1 - 1
packages/compaction/compaction-image-offload/tsconfig.json

@@ -10,7 +10,7 @@
   "references": [
     { "path": "../../../vendor/cosmokit" },
     { "path": "../../../vendor/cordis" },
-    { "path": "../../../vendor/schemastery" },
+    { "path": "../compaction" },
     { "path": "../../llm/llm" },
     { "path": "../../core/session" },
     { "path": "../../core/agent" }

+ 2 - 2
packages/compaction/compaction/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/compaction/compaction/README.md
-README.md: 101a549be11d94ec1e11a55645775f79afabdff8
-README.zh.md: 9e048fae5edf62679044e212b0bdd1c2f3753ebe
+README.md: fecb3b6731108c493825f6fa0874ab865781fc2a
+README.zh.md: 86e6a2935c408af7e247235378a022ad855c00a0

+ 2 - 0
packages/compaction/compaction/README.md

@@ -81,6 +81,8 @@ A backend that summarizes through `ctx.llm.stream()` must forward the abort sign
 
 ### Manual failure taxonomy
 
+The synchronous `compaction/summary-error` waterfall lets a backend request durable input recovery for a failed summary and an explicit selection. A listener returns true only after recording progress, or calls `next()` to delegate. The backend checks cancellation and selection stability before dispatch, then re-derives and re-prices input before retrying. The event declaration documents its payload; image policy belongs to `dsh-compaction-image-offload`.
+
 Expected manual failures throw `ManualCompactionError` with a stable `code` from a small closed set; only failures after the `compaction/start` marker are recorded — as a `compaction/end` carrying the error — while a `busy` rejection or pre-start cancellation leaves no record. The per-code semantics live in the [compaction subsystem reference](../../../docs/subsystems/compaction.md).
 
 <a id="tool-pairing-boundaries"></a>

+ 2 - 0
packages/compaction/compaction/README.zh.md

@@ -81,6 +81,8 @@ kind: "package-reference"
 
 ### 手动失败分类
 
+同步的 `compaction/summary-error` waterfall 让后端为失败的摘要及明确选区请求持久输入恢复。监听器只有在记录实际进展后才返回 true,否则调用 `next()` 委派后续处理。后端在触发事件前检查取消和选区稳定性,重试前重新派生输入并计价。事件声明说明其载荷;图片策略由 `dsh-compaction-image-offload` 负责。
+
 预期手动失败会抛出 `ManualCompactionError`,携带来自小型封闭集合的稳定 `code`;只有 `compaction/start` 标记之后发生的失败才会被记录——以携带错误的 `compaction/end` 形式——而 `busy` 拒绝或 start 之前的取消不会留下记录。每个错误码的语义见[压缩子系统参考](../../../docs/subsystems/compaction.zh.md)。
 
 <a id="tool-pairing-boundaries"></a>

+ 16 - 0
packages/compaction/compaction/src/index.ts

@@ -82,6 +82,22 @@ declare module '@deepseek-ai/cordis' {
   interface Context {
     compaction: CompactionEngine
   }
+  interface Events {
+    /**
+     * Recover a failed summary request by synchronously recording a durable
+     * change to its selected input. Return true only after making progress;
+     * the provider re-derives and re-prices the selection before retrying.
+     * Call next() when the failure cannot be recovered. Decisions survive a
+     * later summary failure or cancellation.
+     * @param payload.session - session containing the selected input.
+     * @param payload.sourceEventSeqs - selected message events in request order.
+     * @param payload.error - failure thrown by the summarizer.
+     * @param payload.signal - optional compaction cancellation signal.
+     * @param next - delegate to the next recovery listener.
+     * @mode waterfall
+     */
+    'compaction/summary-error'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean
+  }
 }
 
 /**

+ 8 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3202,6 +3202,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation.',
     parameters: [],
   },
+  {
+    name: 'compaction/summary-error',
+    mode: 'waterfall',
+    signature: '\'compaction/summary-error\'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean',
+    summary: 'Recover a failed summary request by synchronously recording a durable change to its selected input.',
+    description: 'Recover a failed summary request by synchronously recording a durable change to its selected input. Return true only after making progress; the provider re-derives and re-prices the selection before retrying. Call next() when the failure cannot be recovered. Decisions survive a later summary failure or cancellation.',
+    parameters: [{ name: 'payload', description: '.signal - optional compaction cancellation signal.' }, { name: 'next', description: 'delegate to the next recovery listener.' }],
+  },
   {
     name: 'cordis/dynamic-package',
     mode: 'emit',

+ 2 - 2
packages/llm/llm-deepseek/src/serialize.ts

@@ -394,8 +394,8 @@ export function serializeRequest(
 /**
  * Reject a request whose retained occurrences, at their exact request-version
  * byte lengths under this representation, still exceed the route budget. The
- * failure names how many more of the oldest retained occurrences the agent
- * loop must offload; the surface, not this serializer, owns the offloaded set.
+ * failure names how many more oldest retained occurrences need durable
+ * omission before the request can be retried.
  */
 function assertRetainedImagesFit(messages: readonly Message[], images: ImageSerializationOptions): void {
   const representation = images.representation.kind === 'file' ? 'raw' : 'base64'

+ 2 - 3
packages/llm/llm/src/content.ts

@@ -211,12 +211,11 @@ function base64Length(bytes: number): number {
 
 /**
  * Visit every image occurrence of typed content in message order, including
- * nested tool-result content. Budget accounting, adapter validation, and
- * pricing share this request-content walk.
+ * nested tool-result content.
  * @param content - typed model content blocks.
  * @param visit - called once per occurrence.
  */
-export function visitImageBlocks(content: readonly ContentBlock[], visit: (block: ImageBlock) => void): void {
+function visitImageBlocks(content: readonly ContentBlock[], visit: (block: ImageBlock) => void): void {
   for (const block of content) {
     if (block.type === 'image') visit(block)
     else if (block.type === 'tool-result') visitImageBlocks(block.content, visit)

+ 2 - 3
packages/llm/llm/src/error.ts

@@ -166,8 +166,7 @@ export function isHarnessError(value: unknown): value is HarnessError {
  * Canonical code for a request an image-capable route cannot send until more
  * of its images are offloaded. The failure's `offloadImages` names how many
  * more of the oldest retained occurrences must be offloaded;
- * `dsh-compaction-image-offload` replaces the surface nodes carrying them
- * with copies marked `offloaded` and retries the step, so the model never
- * receives an unlogged projection.
+ * `dsh-compaction-image-offload` records an `image/offload` selection before
+ * the agent or summarizer retries with freshly derived input.
  */
 export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED'

+ 8 - 4
packages/llm/llm/tests/content.spec.ts

@@ -13,7 +13,6 @@ import {
   requiredImageOffload,
   resolveImageAttachmentAccess,
   requestImageHandleText,
-  visitImageBlocks,
 } from '../src/index.ts'
 import type { ContentBlock } from '../src/index.ts'
 
@@ -35,10 +34,10 @@ function image(bytes: number, offloaded?: true): Extract<ContentBlock, { type: '
   }
 }
 
-describe('visitImageBlocks', () => {
+describe('requiredImageOffload traversal', () => {
   it('visits image occurrences at every tool-result depth in message order', () => {
     const seen: number[] = []
-    visitImageBlocks([
+    const content: ContentBlock[] = [
       { type: 'text', text: 'before' },
       image(1),
       {
@@ -55,7 +54,12 @@ describe('visitImageBlocks', () => {
         ],
       },
       image(3),
-    ], block => seen.push(block.attachment.bytes))
+    ]
+    expect(requiredImageOffload([createUserMessage({ content, source })],
+      { representation: 'raw', maxImages: 0 }, (block) => {
+        seen.push(block.attachment.bytes)
+        return block.attachment.bytes
+      })).toBe(4)
     expect(seen).toEqual([1, 2, 4, 3])
   })
 })

+ 15 - 4
pnpm-lock.yaml

@@ -4714,10 +4714,6 @@ importers:
         version: link:../../core/tools
 
   packages/compaction/compaction-image-offload:
-    dependencies:
-      '@deepseek-ai/schemastery':
-        specifier: link:../../../vendor/schemastery
-        version: link:../../../vendor/schemastery
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -4731,15 +4727,30 @@ importers:
       '@deepseek-ai/dsh-agent-loop-testkit':
         specifier: workspace:^
         version: link:../../test-support/agent-loop-testkit
+      '@deepseek-ai/dsh-attachment':
+        specifier: workspace:^
+        version: link:../../attachment/attachment
+      '@deepseek-ai/dsh-compaction':
+        specifier: workspace:^
+        version: link:../compaction
+      '@deepseek-ai/dsh-compaction-basic':
+        specifier: workspace:^
+        version: link:../compaction-basic
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-llm-deepseek':
+        specifier: workspace:^
+        version: link:../../llm/llm-deepseek
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../../session/session-projection
+      '@deepseek-ai/dsh-token-meter':
+        specifier: workspace:^
+        version: link:../../llm/token-meter
 
   packages/compaction/compaction-tool-result-pruner:
     dependencies:

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -196,6 +196,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
   'api-session': 'session.md',
   'approval': 'approval.md',
   'commands': 'commands.md',
+  'compaction': 'compaction.md',
   'cordis': 'extensions.md',
   'authorization': 'credentials.md',
   'credentials': 'credentials.md',

+ 3 - 0
snapshots/acp/image-compaction/cordis.snapshot.yml

@@ -62,3 +62,6 @@
   name: '@deepseek-ai/dsh-compaction-basic'
   config:
     retainTokens: 100
+
+- insert:
+    - name: './summary-image-budget.mjs'

+ 3 - 0
snapshots/acp/image-compaction/cordis.yml

@@ -40,3 +40,6 @@
   name: '@deepseek-ai/dsh-compaction-basic'
   config:
     retainTokens: 100
+
+- insert:
+    - name: './summary-image-budget.mjs'

+ 23 - 0
snapshots/acp/image-compaction/summary-image-budget.mjs

@@ -0,0 +1,23 @@
+/** Authored summary-route rejection for the image-compaction snapshot. */
+export const name = 'summary-image-budget'
+export const inject = ['llm']
+
+/**
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local context.
+ */
+export function apply(ctx) {
+  let failed = false
+  ctx.on('llm/stream', async function* (options, next) {
+    if (options.purpose !== 'compaction' || failed) {
+      yield* next()
+      return
+    }
+    failed = true
+    yield {
+      type: 'finish',
+      reason: { kind: 'error', failure: {
+        code: 'IMAGE_OFFLOAD_REQUIRED', message: 'authored summary image budget failure', offloadImages: 1,
+      } },
+    }
+  })
+}