Explorar o código

refactor(compaction): own image offload as a compaction executor

Move the IMAGE_OFFLOAD_REQUIRED recovery out of llm-retry into the new
@deepseek-ai/dsh-compaction-image-offload package, a sibling of the
tool-result pruner: image offload is compaction in another capacity
dimension. Each replaced node is priced through the seam's compaction/prune
shadow-price protocol before its marked copy lands. llm-retry returns to
master; the base bundle mounts the executor.
creatixchu hai 1 mes
pai
achega
4779f054b6
Modificáronse 49 ficheiros con 559 adicións e 91 borrados
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md
  4. 3 0
      apps/cli/composition.md
  5. 2 2
      docs/config-catalog.i18n.yaml
  6. 14 1
      docs/config-catalog.md
  7. 14 1
      docs/config-catalog.zh.md
  8. 2 2
      docs/event-producer-consumer.i18n.yaml
  9. 1 1
      docs/event-producer-consumer.md
  10. 1 1
      docs/event-producer-consumer.zh.md
  11. 2 2
      docs/module-graph.i18n.yaml
  12. 7 0
      docs/module-graph.md
  13. 7 0
      docs/module-graph.zh.md
  14. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  15. 3 3
      docs/subsystems/llm-streaming.md
  16. 3 3
      docs/subsystems/llm-streaming.zh.md
  17. 5 0
      packages/bundle/base/cordis.patch.yml
  18. 1 0
      packages/bundle/base/package.json
  19. 2 2
      packages/compaction/README.i18n.yaml
  20. 2 1
      packages/compaction/README.md
  21. 2 1
      packages/compaction/README.zh.md
  22. 6 0
      packages/compaction/compaction-image-offload/README.i18n.yaml
  23. 106 0
      packages/compaction/compaction-image-offload/README.md
  24. 106 0
      packages/compaction/compaction-image-offload/README.zh.md
  25. 50 0
      packages/compaction/compaction-image-offload/package.json
  26. 31 14
      packages/compaction/compaction-image-offload/src/image-offload.ts
  27. 40 0
      packages/compaction/compaction-image-offload/src/index.ts
  28. 40 4
      packages/compaction/compaction-image-offload/tests/image-offload.spec.ts
  29. 20 0
      packages/compaction/compaction-image-offload/tsconfig.json
  30. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  31. 1 1
      packages/llm/llm-deepseek/README.md
  32. 1 1
      packages/llm/llm-deepseek/README.zh.md
  33. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  34. 1 1
      packages/llm/llm-pi-ai/README.md
  35. 1 1
      packages/llm/llm-pi-ai/README.zh.md
  36. 2 2
      packages/llm/llm-retry/README.i18n.yaml
  37. 4 5
      packages/llm/llm-retry/README.md
  38. 4 5
      packages/llm/llm-retry/README.zh.md
  39. 0 7
      packages/llm/llm-retry/src/index.ts
  40. 2 2
      packages/llm/llm/README.i18n.yaml
  41. 0 0
      packages/llm/llm/README.md
  42. 1 1
      packages/llm/llm/README.zh.md
  43. 4 3
      packages/llm/llm/src/error.ts
  44. 3 3
      packages/llm/llm/src/types.ts
  45. 45 5
      pnpm-lock.yaml
  46. 1 0
      python/sdk-runtime/package.json
  47. 3 2
      snapshots/sdk/inline-image-prompt/session.v2.jsonl
  48. 1 0
      tsconfig.base.json
  49. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.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-02-durable-image-offload.md
-2026-09-02-durable-image-offload.md: db77e4743f1f81c7425b3dd63d32b36f161eafec
-2026-09-02-durable-image-offload.zh.md: 9b0a424aff24acea130980a61095929b834a3145
+2026-09-02-durable-image-offload.md: d4cc791deec64567b6920c878cb39439fb51c24d
+2026-09-02-durable-image-offload.zh.md: b31a4747db98328ea445d5891d1c813631aeaa94

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md

@@ -22,9 +22,9 @@ The offload of an image occurrence is a durable surface fact recorded the way co
 
 **Adapters project, never decide.** An image-capable route enforces an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta) over the retained occurrences' exact request-version bytes. When they still exceed the budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. Nothing plans an offload before dispatch.
 
-**Recovery owned by `dsh-llm-retry`.** The retry executor already sits on the `agent/request-error` waterfall. Before any provider policy it handles `IMAGE_OFFLOAD_REQUIRED`: it walks the surface in model request order, marks the first `offloadImages` retained occurrences, appends one replacement per node that carried any of them, and returns the `retry` action without spending the provider retry budget or logging `llm/retry`. Assistant nodes carry model output, not input images, and are skipped. When nothing remains to offload it delegates and the failure reaches ordinary recovery. The loop re-runs the step over the replaced surface and logs the fresh `request/header` every surface replacement gets; the agent loop is unchanged.
+**Recovery owned by `dsh-compaction-image-offload`.** Image offload is compaction in another capacity dimension: the provider rejects a request, durable history is reduced, the step retries. The executor is a sibling of `compaction-tool-result-pruner` in the compaction group and listens on the `agent/request-error` waterfall. On `IMAGE_OFFLOAD_REQUIRED` it walks the surface in model request order, marks the first `offloadImages` retained occurrences, and for each node that carried one appends the seam's `compaction/prune` shadow price followed by the marked copy, then returns the `retry` action without spending the provider retry budget or logging `llm/retry`. Assistant nodes carry model output, not input images, and are skipped. When nothing remains to offload it delegates and the failure reaches ordinary recovery. The loop re-runs the step over the replaced surface and logs the fresh `request/header` every surface replacement gets; the agent loop is unchanged.
 
-**Token accounting.** `priceImages` receives the surface's `ImageBlock`s and prices a marked one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter needs no new state: a replacement reprices the node like any other replacement.
+**Token accounting.** `priceImages` receives the surface's `ImageBlock`s and prices a marked one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter needs no new state: the `compaction/prune` event and the replacement reprice the node like a tool-result prune.
 
 **Other consumers.** Compaction reconstructs each selected event through `deriveEventMessage()` and sees the marks. Resume, fork, and replay reproduce the surface from the log. Text-only routes keep their separate whole-history substitution.
 
@@ -52,4 +52,4 @@ Every dispatched request's image set is determined by the log alone, across file
 
 ## Testing
 
-`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, the offload count including the 129-to-64 MiB quantum example, and placeholder projection. `packages/llm/llm-retry/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` replace-and-retry path without a retry event, the untouched original node, request-order counts after an earlier surface replacement, and the exhausted case that delegates downstream. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins placeholder pricing for a marked replacement. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the replacement and the retried request.
+`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, the offload count including the 129-to-64 MiB quantum example, and placeholder projection. `packages/compaction/compaction-image-offload/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` replace-and-retry path with its `compaction/prune` shadow prices and without a retry event, the untouched original node, request-order counts after an earlier surface replacement, and the exhausted case that delegates downstream. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins placeholder pricing for a marked replacement. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the replacement and the retried request.

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md

@@ -22,9 +22,9 @@ Status: implemented
 
 **adapter 只投影,不决定。** 支持图片的路由按保留的出现位置的精确请求版本字节执行一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages` 与两个量子)。当它们仍超过预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。发送前没有任何规划。
 
-**恢复归 `dsh-llm-retry`。** 重试执行器本来就在 `agent/request-error` waterfall 上。它在任何提供方策略之前处理 `IMAGE_OFFLOAD_REQUIRED`:按模型请求顺序遍历表层,给前 `offloadImages` 个保留的出现位置打标记,为每个承载了其中任一位置的节点追加一条替换,然后返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。assistant 节点承载的是模型输出而不是输入图片,直接跳过。没有可省略的出现位置时向下游委托,失败进入普通恢复路径。循环在替换后的表层上重跑该 step,并像每次表层替换后一样记录新的 `request/header`;agent loop 不变。
+**恢复归 `dsh-compaction-image-offload`。** 图片省略是 compaction 在另一个容量维度上的实例:provider 拒绝请求,持久历史被缩减,step 重试。执行器是 compaction 组里 `compaction-tool-result-pruner` 的兄弟包,监听 `agent/request-error` waterfall。收到 `IMAGE_OFFLOAD_REQUIRED` 时,它按模型请求顺序遍历表层,给前 `offloadImages` 个保留的出现位置打标记,为每个承载了其中任一位置的节点先追加 seam 的 `compaction/prune` 影子价格,再追加带标记的副本,然后返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。assistant 节点承载的是模型输出而不是输入图片,直接跳过。没有可省略的出现位置时向下游委托,失败进入普通恢复路径。循环在替换后的表层上重跑该 step,并像每次表层替换后一样记录新的 `request/header`;agent loop 不变。
 
-**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把带标记的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 不需要新状态:一次替换和其他替换一样重新为该节点定价。
+**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把带标记的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 不需要新状态:`compaction/prune` 事件加替换节点,和工具结果剪枝一样重新为该节点定价。
 
 **其他消费方。** compaction 通过 `deriveEventMessage()` 重建每个选中事件,看得到标记。resume、fork 和重放从日志复现表层。纯文本路由保留各自的全历史替换。
 
@@ -52,4 +52,4 @@ Status: implemented
 
 ## 测试
 
-`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、包括 129 到 64 MiB 量子示例在内的省略计数,以及占位投影。`packages/llm/llm-retry/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 替换并重试且不追加重试事件的路径、原节点保持不变、更早一次表层替换之后的请求顺序计数,以及向下游委托的耗尽情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住带标记替换的占位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住替换和重试后的请求。
+`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、包括 129 到 64 MiB 量子示例在内的省略计数,以及占位投影。`packages/compaction/compaction-image-offload/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 替换并重试、带 `compaction/prune` 影子价格且不追加重试事件的路径、原节点保持不变、更早一次表层替换之后的请求顺序计数,以及向下游委托的耗尽情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住带标记替换的占位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住替换和重试后的请求。

+ 3 - 0
apps/cli/composition.md

@@ -150,6 +150,8 @@ flowchart LR
   cfg --> plugin_dsh_base_session_checkpoint_policy
   plugin_dsh_base_tool_result_pruner["tool-result-pruner<br/>@deepseek-ai/dsh-compaction-tool-result-pruner"]
   cfg --> plugin_dsh_base_tool_result_pruner
+  plugin_dsh_base_image_offload["image-offload<br/>@deepseek-ai/dsh-compaction-image-offload"]
+  cfg --> plugin_dsh_base_image_offload
   plugin_dsh_base_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
   cfg --> plugin_dsh_base_tool_todo
   plugin_dsh_base_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
@@ -251,6 +253,7 @@ flowchart LR
 | `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
 | `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
 | `tool-result-pruner` | `@deepseek-ai/dsh-compaction-tool-result-pruner` |
+| `image-offload` | `@deepseek-ai/dsh-compaction-image-offload` |
 | `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
 | `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
 | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |

+ 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: 826100d4c7847d05fda64e0a884ad508516ca1db
-config-catalog.zh.md: d72bf693e36a16d1e67ce9f11527175ec3aecf60
+config-catalog.md: 834b11f8c75a284bbe611583324d1acc6cde23dd
+config-catalog.zh.md: d490d312ea380b2a0703f2f97357a46f6d45eb2e

+ 14 - 1
docs/config-catalog.md

@@ -482,6 +482,19 @@ 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` · `tokenMeter`
+
+```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:23`](../packages/compaction/compaction-image-offload/src/index.ts)
+
 <a id="deepseek-aidsh-compaction-tool-result-pruner"></a>
 
 ## `@deepseek-ai/dsh-compaction-tool-result-pruner`
@@ -1451,7 +1464,7 @@ Requires: `agents` · `sessionProjections`
 export type Config = Readonly<Record<string, never>>
 ```
 
-Source: [`packages/llm/llm-retry/src/index.ts:27`](../packages/llm/llm-retry/src/index.ts)
+Source: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts)
 
 <a id="deepseek-aidsh-lsp-stdio"></a>
 

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

@@ -484,6 +484,19 @@ 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` · `tokenMeter`
+
+```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:23`](../packages/compaction/compaction-image-offload/src/index.ts)
+
 <a id="deepseek-aidsh-compaction-tool-result-pruner"></a>
 
 ## `@deepseek-ai/dsh-compaction-tool-result-pruner`
@@ -1453,7 +1466,7 @@ export interface ReplayModelConfig {
 export type Config = Readonly<Record<string, never>>
 ```
 
-来源:[`packages/llm/llm-retry/src/index.ts:27`](../packages/llm/llm-retry/src/index.ts)
+来源:[`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts)
 
 <a id="deepseek-aidsh-lsp-stdio"></a>
 

+ 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: 99811d1dac8b51e96544eea95b1d67379cc15f14
-event-producer-consumer.zh.md: 68ea7460eeb2192b6d8d5195fec1254c457e7c16
+event-producer-consumer.md: da13ad19a662de1292945ede102fa26637e75fd0
+event-producer-consumer.zh.md: cf4551bcb50887c0df4d2679e3ddfcf71093e9b3

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

@@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |

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

@@ -20,7 +20,7 @@
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |

+ 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: ecb72f02bc7a27a75318e8abf56c20f9915831ea
-module-graph.zh.md: 5c94c866d10e81948dd69089210cd4b08ded7531
+module-graph.md: 02660e82972c86bf3e7ea4a2a4e42f419ce42728
+module-graph.zh.md: 69533d8c4853cf28e72a353986987edb707b3066

+ 7 - 0
docs/module-graph.md

@@ -193,6 +193,7 @@ flowchart TD
     pkg_command_compact["command-compact"]
     pkg_compaction["compaction"]
     pkg_compaction_basic["compaction-basic"]
+    pkg_compaction_image_offload["compaction-image-offload"]
     pkg_compaction_tool_result_pruner["compaction-tool-result-pruner"]
   end
   subgraph group_context["packages/context"]
@@ -914,6 +915,11 @@ flowchart TD
   pkg_api_settings_controller --> pkg_typert_protocol
   pkg_web_app --> pkg_shell_env
   pkg_web_app --> pkg_system_prompt
+  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_compaction_image_offload --> pkg_token_meter
   pkg_compaction_tool_result_pruner --> pkg_compaction
   pkg_compaction_tool_result_pruner --> pkg_llm
   pkg_compaction_tool_result_pruner --> pkg_session
@@ -1399,6 +1405,7 @@ flowchart TD
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
+| [`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), [`token-meter`](../packages/llm/token-meter) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |

+ 7 - 0
docs/module-graph.zh.md

@@ -195,6 +195,7 @@ flowchart TD
     pkg_command_compact["command-compact"]
     pkg_compaction["compaction"]
     pkg_compaction_basic["compaction-basic"]
+    pkg_compaction_image_offload["compaction-image-offload"]
     pkg_compaction_tool_result_pruner["compaction-tool-result-pruner"]
   end
   subgraph group_context["packages/context"]
@@ -916,6 +917,11 @@ flowchart TD
   pkg_api_settings_controller --> pkg_typert_protocol
   pkg_web_app --> pkg_shell_env
   pkg_web_app --> pkg_system_prompt
+  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_compaction_image_offload --> pkg_token_meter
   pkg_compaction_tool_result_pruner --> pkg_compaction
   pkg_compaction_tool_result_pruner --> pkg_llm
   pkg_compaction_tool_result_pruner --> pkg_session
@@ -1401,6 +1407,7 @@ flowchart TD
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
+| [`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), [`token-meter`](../packages/llm/token-meter) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/subsystems/llm-streaming.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/llm-streaming.md
-llm-streaming.md: b556bf14c545f29ab3551e40dffeb354f3f0cb1d
-llm-streaming.zh.md: a6c2dfa284be6be0e8e501391b98d499dca844ac
+llm-streaming.md: 96d614e3db068c440f246622fbf1512123654a70
+llm-streaming.zh.md: a5bc55dc9472ea986b5c0de07eebaa2028b91454

+ 3 - 3
docs/subsystems/llm-streaming.md

@@ -247,9 +247,9 @@ interface LlmFailure {
   /**
    * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained
    * image occurrences the route needs offloaded before the same request fits
-   * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes
-   * carrying that many oldest retained occurrences with copies marked
-   * `offloaded` and retries the step.
+   * its exact byte accounting. `dsh-compaction-image-offload` replaces the
+   * surface nodes carrying that many oldest retained occurrences with copies
+   * marked `offloaded` and retries the step.
    */
   readonly offloadImages?: number
 }

+ 3 - 3
docs/subsystems/llm-streaming.zh.md

@@ -249,9 +249,9 @@ interface LlmFailure {
   /**
    * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained
    * image occurrences the route needs offloaded before the same request fits
-   * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes
-   * carrying that many oldest retained occurrences with copies marked
-   * `offloaded` and retries the step.
+   * its exact byte accounting. `dsh-compaction-image-offload` replaces the
+   * surface nodes carrying that many oldest retained occurrences with copies
+   * marked `offloaded` and retries the step.
    */
   readonly offloadImages?: number
 }

+ 5 - 0
packages/bundle/base/cordis.patch.yml

@@ -398,6 +398,11 @@
         headChars: 4096
         tailChars: 1024
 
+    # When an image-capable route rejects a request as over its image budget,
+    # replace the oldest images with placeholders on the surface and retry.
+    - id: image-offload
+      name: '@deepseek-ai/dsh-compaction-image-offload'
+
     - id: tool-todo
       name: '@deepseek-ai/dsh-tool-todo'
       config:

+ 1 - 0
packages/bundle/base/package.json

@@ -47,6 +47,7 @@
     "@deepseek-ai/dsh-command-goal": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction-basic": "workspace:^",
+    "@deepseek-ai/dsh-compaction-image-offload": "workspace:^",
     "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
     "@deepseek-ai/dsh-credentials-local": "workspace:^",
     "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",

+ 2 - 2
packages/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/README.md
-README.md: dbab0fa400026f965e1fcbe8af88e0873bb560e4
-README.zh.md: 5584c180d63321aac7fa23b4f87157520b7a3b47
+README.md: 46ab8fab72d55b4926c9fe582187e025ce7a67d0
+README.zh.md: ca1ccab9f980cd4e4ba7632f5dd148e9d02cbdd4

+ 2 - 1
packages/compaction/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The `compaction/` group keeps long agent conversations working near the model's context limit: older history is condensed into a summary automatically as token pressure builds, on demand with `/compact`, and oversized tool outputs can be trimmed first so there is less to condense. The shipped `dsh` base enables the feature by default — mount the packages explicitly to tune when and how condensation happens. The token measurement that decides when to condense lives in a separate LLM-family service.
+The `compaction/` group keeps long agent conversations working near the model's context limit: older history is condensed into a summary automatically as token pressure builds, on demand with `/compact`, oversized tool outputs can be trimmed first so there is less to condense, and images an image-capable route can no longer send are replaced with placeholders. The shipped `dsh` base enables the feature by default — mount the packages explicitly to tune when and how condensation happens. The token measurement that decides when to condense lives in a separate LLM-family service.
 
 ## Table of Contents
 
@@ -29,6 +29,7 @@ Each package below provides one piece of the feature; open a package page for ho
 | [`compaction/`](compaction/README.md) | The shared condensation contract: the operations and summary format every backend and trigger use | `ctx.compaction` |
 | [`compaction-basic/`](compaction-basic/README.md) | Automatic condensation of older history into a summary as token pressure builds | registers `ctx.compaction` |
 | [`compaction-tool-result-pruner/`](compaction-tool-result-pruner/README.md) | Trims oversized tool outputs so less history needs condensing | `ctx.toolResultPruner` |
+| [`compaction-image-offload/`](compaction-image-offload/README.md) | Replaces over-budget request images with placeholders when an image-capable route rejects a request | listens to `agent/request-error` |
 | [`command-compact/`](command-compact/README.md) | The `/compact` command to condense history on demand | registers on `ctx.commands` |
 
 -----

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

@@ -9,7 +9,7 @@ kind: "package-group"
 
 ## 概述
 
-`compaction/` 组让长时 agent 会话在接近模型上下文上限时仍能正常工作:token 压力上升时自动把较早历史压缩为摘要,随时可用 `/compact` 按需压缩,超大工具输出也可以先被修剪,从而减少需要压缩的内容。随附 `dsh` 基础配置默认启用该功能——显式挂载各包即可调整压缩发生的时机与方式。决定何时压缩的 token 测量属于独立的 LLM(大语言模型)家族服务。
+`compaction/` 组让长时 agent 会话在接近模型上下文上限时仍能正常工作:token 压力上升时自动把较早历史压缩为摘要,随时可用 `/compact` 按需压缩,超大工具输出也可以先被修剪,从而减少需要压缩的内容,支持图片的路由发不出的图片则被替换为占位文本。随附 `dsh` 基础配置默认启用该功能——显式挂载各包即可调整压缩发生的时机与方式。决定何时压缩的 token 测量属于独立的 LLM(大语言模型)家族服务。
 
 ## 目录
 
@@ -29,6 +29,7 @@ kind: "package-group"
 | [`compaction/`](compaction/README.zh.md) | 共享的压缩约定:所有后端与触发器使用的操作与摘要格式 | `ctx.compaction` |
 | [`compaction-basic/`](compaction-basic/README.zh.md) | 随 token 压力上升自动把较早历史压缩为摘要 | 注册 `ctx.compaction` |
 | [`compaction-tool-result-pruner/`](compaction-tool-result-pruner/README.zh.md) | 修剪超大工具输出,减少需要压缩的历史 | `ctx.toolResultPruner` |
+| [`compaction-image-offload/`](compaction-image-offload/README.zh.md) | 支持图片的路由拒绝请求时,把超出预算的请求图片替换为占位文本 | 监听 `agent/request-error` |
 | [`command-compact/`](command-compact/README.zh.md) | 按需压缩历史的 `/compact` 命令 | 注册到 `ctx.commands` |
 
 -----

+ 6 - 0
packages/compaction/compaction-image-offload/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md
+README.md: 7a912557b1d77aeaf7066a3ba07c0332670cf192
+README.zh.md: b5bb0d5f38e28b87a97374dd8201b220dd936b0c

+ 106 - 0
packages/compaction/compaction-image-offload/README.md

@@ -0,0 +1,106 @@
+---
+description: "The image offload executor for deployments composing compaction: what happens when an image-capable route rejects a request as over its image budget."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-compaction-image-offload
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-compaction-image-offload` keeps image-heavy conversations sendable. When an image-capable route fails a request with `IMAGE_OFFLOAD_REQUIRED`, naming how many of the oldest retained images no longer fit its budget, the plugin replaces each surface node carrying one of those images with a copy whose image blocks are marked `offloaded`, prices the replaced node through the compaction seam's `compaction/prune` protocol, and retries the step. Every route then sends placeholder text naming the image and its read-only path instead of the image. Like the other compaction executors, the replacement is a durable surface fact: it never reverts, so the images the model sees and the provider cache prefix move only forward.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount this plugin in every composition that runs the agent loop with an image-capable route and the token meter. The shipped `dsh` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded.
+
+### Minimal configuration
+
+```yaml
+- name: '@deepseek-ai/dsh-compaction-image-offload'
+```
+
+### What you can observe
+
+Each offload appends, per replaced node, one `compaction/prune` event carrying the node's heuristic token price and then the replacement node itself: a `user/message` or `tool/result` copy of the original with the affected image blocks marked `offloaded: true`, a `surfaceOp` of `replace`, and `sourceEventSeqs` naming the original. The original event stays in the log untouched. The retried request follows; the loop logs a fresh `request/header` after the surface change, as it does after any compaction.
+
+### Failures and recovery
+
+The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadImages`. It walks the surface in model request order, skips assistant nodes and images already marked, and marks that many occurrences. When nothing remains to offload it delegates on the `agent/request-error` waterfall, so downstream recovery or the ordinary turn error applies. The retry spends no provider retry budget and logs no `llm/retry` event.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The plugin is one function plugin with one `agent/request-error` listener. `offloadOldestImages()` walks `session.surface.nodes`, marks the first `offloadImages` retained occurrences across `user/message` and `tool/result` nodes, nested tool results included, and for each changed node appends the `compaction/prune` shadow price followed by the marked copy under `surfaceOp: { op: 'replace' }`. It reuses the compaction seam's events and the session's replacement mechanism; no session or agent-loop change exists for it.
+
+No runtime invariant companion is published: Session validates each replacement's surface metadata, and the `compaction/prune` protocol is owned by the compaction seam's companion.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Durable image offload by surface replacement](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md) — the decision this executor implements and the alternatives it replaced.
+- [compaction seam](../compaction/README.md) — the `compaction/prune` shadow-price protocol.
+- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs by the same replacement mechanism.
+- [dsh-llm](../../llm/llm/README.md) — `ImageBlock.offloaded`, `IMAGE_OFFLOAD_REQUIRED`, and the placeholder projection.
+- [llm-deepseek adapter](../../llm/llm-deepseek/README.md) and [llm-pi-ai adapter](../../llm/llm-pi-ai/README.md) — the route budgets that report offload counts.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+### Offloaded request images
+
+#### What the model sees
+
+Every image occurrence a replacement marked reaches the model as the route's placeholder text (`offloadedImageText`) naming the attachment and its read-only path instead of the image; unmarked occurrences stay images. The set never shrinks on its own, so the model can rely on an offloaded image staying offloaded and read it back through the path when it needs the content again.
+
+#### Token effect
+
+An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter prices the replaced node from the marks on the surface.
+
+#### KV Cache effect
+
+A replacement turns earlier images into placeholder text, so provider cache reuse ends at the first replaced message for that request. Because the replacement never reverts, the prefix stays stable afterwards.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **Offloaded images never return automatically** — a larger budget, a larger route, or compaction lowering the total leaves the marks in place; recovery is the read-only path in the placeholder.
+- **Every offload costs one failed attempt** — nothing plans an offload before dispatch; the route's rejection is the signal, which matches the planned move to provider-reported uncacheable images.
+- **A temporary small budget offloads permanently** — a Files outage that forces the inline fallback, or a temporary switch to a small-budget route, offloads images a later route would have sent.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+This Dev Note is non-authoritative working context: notes for maintainers and open questions. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes.
+
+- The route-local byte checks that produce `IMAGE_OFFLOAD_REQUIRED` are provisional: once the provider reports uncacheable images itself, the adapters map that report to a count and this executor stays as it is.
+
+</details>

+ 106 - 0
packages/compaction/compaction-image-offload/README.zh.md

@@ -0,0 +1,106 @@
+---
+description: "面向组合 compaction 的部署的图片省略执行器说明:支持图片的路由以请求超出图片预算拒绝时会发生什么。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-compaction-image-offload
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-compaction-image-offload` 让图片密集的会话保持可发送。支持图片的路由以 `IMAGE_OFFLOAD_REQUIRED` 让请求失败并说明最老的几张保留图片已经装不进预算时,本插件把承载这些图片的每个表层节点替换为图片块标了 `offloaded` 的副本,通过 compaction seam 的 `compaction/prune` 协议为被替换节点计价,然后重试该 step。此后每条路由都以注明图片及其只读路径的占位文本代替图片发送。和其他 compaction 执行器一样,替换是持久的表层事实,永不回退,因此模型看到的图片和 provider 缓存前缀只向前移动。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+凡是运行 agent loop、带有支持图片的路由并挂了 token meter 的组合,都应挂载本插件,随附的 `dsh` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek adapter 执行其 file 模式和内联回退预算,pi-ai adapter 执行其 base64 上限,各自上报需要省略的数量。
+
+### 最小可用组合
+
+```yaml
+- name: '@deepseek-ai/dsh-compaction-image-offload'
+```
+
+### 你可以观察到什么
+
+每次省略会为每个被替换节点追加一条携带该节点启发式 token 价格的 `compaction/prune` 事件,紧接着追加替换节点本身:原消息的 `user/message` 或 `tool/result` 副本,受影响的图片块标了 `offloaded: true`,`surfaceOp` 为 `replace`,`sourceEventSeqs` 指向原节点。原事件原封不动留在日志里。随后是重试的请求;表层变化后 loop 会像任何一次 compaction 之后一样记录新的 `request/header`。
+
+### 失败与恢复
+
+本插件只处理带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败。它按模型请求顺序遍历表层,跳过 assistant 节点和已经标记的图片,给指定数量的出现位置打标记。没有可省略的图片时在 `agent/request-error` waterfall 上委派下游,由下游恢复或普通的轮次错误处理。这次重试不占提供方重试预算,也不追加 `llm/retry` 事件。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内部——点击展开</summary>
+
+本插件是只有一个 `agent/request-error` 监听器的函数插件。`offloadOldestImages()` 遍历 `session.surface.nodes`,在 `user/message` 和 `tool/result` 节点(含嵌套工具结果)中给前 `offloadImages` 个保留的出现位置打标记,对每个有变化的节点先追加 `compaction/prune` 影子价格,再以 `surfaceOp: { op: 'replace' }` 追加带标记的副本。它复用 compaction seam 的事件和 session 的替换机制,没有为它改动 session 或 agent loop。
+
+本包不发布运行时 invariant 伴生插件:Session 校验每次替换的表层元数据,`compaction/prune` 协议由 compaction seam 的伴生插件负责。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [以表层替换实现持久的图片 offload](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md)——本执行器实现的决定及其替代的方案。
+- [compaction seam](../compaction/README.zh.md)——`compaction/prune` 影子定价协议。
+- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md)——用同一替换机制修剪工具输出的兄弟执行器。
+- [dsh-llm](../../llm/llm/README.zh.md)——`ImageBlock.offloaded`、`IMAGE_OFFLOAD_REQUIRED` 与占位投影。
+- [llm-deepseek 适配器](../../llm/llm-deepseek/README.zh.md)与 [llm-pi-ai 适配器](../../llm/llm-pi-ai/README.zh.md)——上报省略数量的路由预算。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+### 已省略的请求图片
+
+#### 模型看到的内容
+
+替换标记过的每个图片出现位置,以路由的占位文本(`offloadedImageText`)到达模型,文本注明附件及其只读路径而不是图片本身;未标记的出现位置仍是图片。该集合不会自行缩小,模型可以确信被省略的图片会一直被省略,再需要内容时通过路径读回。
+
+#### Token 影响
+
+被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 按表层上的标记为被替换节点定价。
+
+#### KV Cache 影响
+
+一次替换把较早的图片换成占位文本,该请求的 provider 缓存复用因此止于第一条被替换的消息。替换永不回退,之后的前缀保持稳定。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **被省略的图片不会自动回归**——更大的预算、更大的路由或 compaction 降低总量都不会移除标记;恢复手段是占位文本中的只读路径。
+- **每次省略都要先失败一次**——发送前没有任何规划,路由的拒绝就是信号,这和计划中改由 provider 报告无法缓存图片的方向一致。
+- **临时的小预算会永久省略**——迫使内联回退的 Files 故障,或临时切到小预算路由,会省略后来的路由本可以发送的图片。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作背景——点击展开</summary>
+
+本开发备注是非权威的工作背景:给维护者的备注和未决问题。已交付的行为与被接受的理由见上文各节、包代码和链接的 Agent Note。
+
+- 产生 `IMAGE_OFFLOAD_REQUIRED` 的路由本地字节检查是过渡实现:provider 自己报告无法缓存的图片之后,adapter 把该报告换算成数量,本执行器保持不变。
+
+</details>

+ 50 - 0
packages/compaction/compaction-image-offload/package.json

@@ -0,0 +1,50 @@
+{
+  "name": "@deepseek-ai/dsh-compaction-image-offload",
+  "description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/compaction/compaction-image-offload"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "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:^",
+    "@deepseek-ai/dsh-token-meter": "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-compaction": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-token-meter": "workspace:^"
+  }
+}

+ 31 - 14
packages/llm/llm-retry/src/image-offload.ts → packages/compaction/compaction-image-offload/src/image-offload.ts

@@ -1,16 +1,18 @@
 /**
- * Image offload recovery: replace the surface nodes carrying the oldest
- * retained image occurrences with copies that mark them `offloaded`, so the
- * retried request sends placeholder text for exactly the occurrences an
- * adapter's `IMAGE_OFFLOAD_REQUIRED` failure named. The replacement is a
- * durable surface fact: it never reverts, and every later request and
- * measurement reads it from the log.
+ * The image offload replacement: mark the oldest retained image occurrences
+ * on the surface `offloaded` by replacing each carrying node with a marked
+ * copy, priced through the shared `compaction/prune` shadow-price protocol.
+ * A replacement never reverts, so every later request and measurement reads
+ * the offloaded set from the log.
  *
- * @module @deepseek-ai/dsh-llm-retry/image-offload
+ * @module @deepseek-ai/dsh-compaction-image-offload/image-offload
  */
 
-import type { ContentBlock, ToolResultMessage } from '@deepseek-ai/dsh-llm'
+import type { Context } from '@deepseek-ai/cordis'
+import type { ContentBlock, Message, ToolResultMessage } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-compaction'
+import type {} from '@deepseek-ai/dsh-token-meter'
 
 /** Mark the first `remaining.count` retained occurrences, keeping unchanged blocks by identity. */
 function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: number }): readonly ContentBlock[] {
@@ -38,8 +40,22 @@ function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: n
 /** The surface node types that carry model input images. */
 type ImageNode = SessionEvent<'user/message'> | SessionEvent<'tool/result'>
 
-/** Append the marked copy of one surface node in place of the original. */
-function replaceNode(session: Session, event: ImageNode, content: readonly ContentBlock[]): void {
+/** The message one image node derives. */
+function messageOf(event: ImageNode): Message {
+  return event.type === 'user/message' ? event.data : event.data.message
+}
+
+/**
+ * Append the shadow price of one node and then its marked copy in place of
+ * the original, following the `compaction/prune` protocol so pure consumers
+ * subtract the replaced node's heuristic price without per-node state.
+ */
+function replaceNode(ctx: Context, session: Session, event: ImageNode, content: readonly ContentBlock[]): void {
+  session.append('compaction/prune', {
+    shadowedRange: { start: event.seq, end: event.seq },
+    shadowedSeqs: [event.seq],
+    shadowedTokenCount: ctx.tokenMeter.estimateMessage(messageOf(event)),
+  })
   const options = {
     surfaceOp: { op: 'replace' as const, start: event.seq, end: event.seq },
     sourceEventSeqs: [event.seq],
@@ -59,11 +75,12 @@ function replaceNode(session: Session, event: ImageNode, content: readonly Conte
  * order, by replacing each surface node that carries one with a copy whose
  * occurrences are marked `offloaded`. Assistant nodes carry model output, not
  * input images, and are skipped.
- * @param session - the session whose derived surface the retried request reads.
+ * @param ctx - the plugin context, for the token meter's heuristic price.
+ * @param session - the session whose surface the retried request reads.
  * @param count - how many more oldest retained occurrences the adapter needs offloaded.
  * @returns whether any occurrence remained to offload.
  */
-export function offloadOldestImages(session: Session, count: number): boolean {
+export function offloadOldestImages(ctx: Context, session: Session, count: number): boolean {
   const remaining = { count }
   let replaced = false
   for (const seq of [...session.surface.nodes]) {
@@ -71,10 +88,10 @@ export function offloadOldestImages(session: Session, count: number): boolean {
     // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log
     const event = session.eventAt(seq)!
     if (event.type !== 'user/message' && event.type !== 'tool/result') continue
-    const original = event.type === 'user/message' ? event.data.content : event.data.message.content
+    const original = messageOf(event).content
     const content = markOldestImages(original, remaining)
     if (content === original) continue
-    replaceNode(session, event, content)
+    replaceNode(ctx, session, event, content)
     replaced = true
   }
   return replaced

+ 40 - 0
packages/compaction/compaction-image-offload/src/index.ts

@@ -0,0 +1,40 @@
+/**
+ * Image offload executor for the compaction seam. When an image-capable route
+ * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin replaces the
+ * surface nodes carrying the named count of oldest retained image occurrences
+ * with copies marked `offloaded`, prices each replaced node through the
+ * shared `compaction/prune` protocol, and retries the step on the
+ * `agent/request-error` waterfall. Every route then sends placeholder text
+ * for those occurrences, and the replacement never reverts.
+ *
+ * @module @deepseek-ai/dsh-compaction-image-offload
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import type { RequestErrorAction } from '@deepseek-ai/dsh-agent'
+import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm'
+import { offloadOldestImages } from './image-offload.ts'
+
+export const name = 'compaction-image-offload'
+export const inject = ['agents', 'tokenMeter']
+
+/** 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.
+ * @param ctx - the plugin context.
+ * @param _config - no configuration.
+ */
+export function apply(ctx: Context, _config: Config = {}): 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(ctx, agent.session, failure.offloadImages)) return next()
+    return Promise.resolve<RequestErrorAction>({ kind: 'retry' })
+  })
+}

+ 40 - 4
packages/llm/llm-retry/tests/image-offload.spec.ts → packages/compaction/compaction-image-offload/tests/image-offload.spec.ts

@@ -1,7 +1,8 @@
 /**
  * Image offload recovery: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure
  * replaces the surface nodes carrying the named count of oldest images with
- * marked copies and retries the step without spending the provider retry budget.
+ * marked copies, each priced by a preceding `compaction/prune` event, and
+ * retries the step without a retry event.
  */
 
 import { describe, expect, it } from 'vitest'
@@ -12,7 +13,8 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage, IMA
 import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { isReplacementSurfaceEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
-import * as retry from '../src/index.ts'
+import TokenMeter from '@deepseek-ai/dsh-token-meter'
+import * as offload from '../src/index.ts'
 
 type ScriptEntry = StreamChunk[] | (() => never)
 
@@ -50,7 +52,8 @@ function offloadRequired(offloadImages: number): () => never {
 async function harness(adapter: ScriptedAdapter): Promise<Context> {
   const ctx = new Context()
   await mountAgentLoopTestDependencies(ctx)
-  await ctx.plugin(Object.assign((inner: Context) => { retry.apply(inner, {}) }, { inject: retry.inject }))
+  await ctx.plugin(TokenMeter)
+  await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject }))
   await ctx.plugin(AgentLoop, { agents: [] })
   ctx.llm.registerAdapter(['mock'], adapter)
   return ctx
@@ -82,7 +85,20 @@ function replacements(session: Session): [number, number][] {
     .map(event => [Number(event.sourceEventSeqs?.[0]), Number(event.seq)])
 }
 
-describe('image offload recovery', () => {
+/** Assert each listed replacement is immediately preceded by its `compaction/prune` shadow price. */
+function expectShadowPriced(session: Session, pairs: readonly [number, number][]): void {
+  const events = session.snapshotEvents()
+  for (const [original, replacement] of pairs) {
+    const prune = events[replacement - 1]
+    expect(prune).toMatchObject({
+      type: 'compaction/prune',
+      data: { shadowedRange: { start: original, end: original }, shadowedSeqs: [original] },
+    })
+    expect(prune?.type === 'compaction/prune' ? prune.data.shadowedTokenCount : 0).toBeGreaterThan(0)
+  }
+}
+
+describe('compaction-image-offload', () => {
   it('replaces the node carrying the named count of images and retries without a retry event', async () => {
     const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')])
     const ctx = await harness(adapter)
@@ -113,6 +129,7 @@ describe('image offload recovery', () => {
     expect(events[original]).toMatchObject({ type: 'user/message', surfaceOp: 'append' })
     expect(types.indexOf('assistant/attempt')).toBeLessThan(replacement)
     expect(replacement).toBeLessThan(types.indexOf('assistant/message'))
+    expectShadowPriced(agent.session, replacements(agent.session))
     // The original event keeps its content; only the replacement carries the marks.
     const durable = events[original]!
     expect(durable.type === 'user/message' ? durable.data.content[0] : undefined).not.toHaveProperty('offloaded')
@@ -148,7 +165,9 @@ describe('image offload recovery', () => {
     expect(adapter.requests).toHaveLength(2)
     expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second'])
     // Both nodes carried one occurrence, so the recovery replaced the replacement node and 'second'.
+    // The first replacement is the test's own; the recovery priced the two it appended.
     expect(replacements(agent.session).slice(1).map(([original]) => original)).toEqual([3, 2])
+    expectShadowPriced(agent.session, replacements(agent.session).slice(1))
   })
 
   it('replaces a tool result node and leaves blocks after the count untouched', async () => {
@@ -192,6 +211,23 @@ describe('image offload recovery', () => {
     expect(replaced.type === 'tool/result' ? replaced.data.message.source.callId : undefined).toBe(callId)
   })
 
+  it('leaves every other failure to downstream recovery', async () => {
+    const adapter = new ScriptedAdapter([() => {
+      throw new LlmError('provider outage', 'SERVER')
+    }])
+    const ctx = await harness(adapter)
+    const agent = await ctx.agentLoop.create(SessionId('offload-other-failure'), { provider: 'mock', model: 'mock' })
+    const delegated: string[] = []
+    ctx.on('agent/request-error', ({ failure }, next) => {
+      delegated.push(failure.code)
+      return next()
+    })
+    agent.followup(createUserMessage({ content: [image('a')], source: { kind: 'user' } }))
+    await agent.whenIdle()
+    expect(delegated).toEqual(['SERVER'])
+    expect(replacements(agent.session)).toHaveLength(0)
+  })
+
   it('delegates IMAGE_OFFLOAD_REQUIRED once nothing remains to offload', async () => {
     const adapter = new ScriptedAdapter([offloadRequired(1)])
     const ctx = await harness(adapter)

+ 20 - 0
packages/compaction/compaction-image-offload/tsconfig.json

@@ -0,0 +1,20 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/schemastery" },
+    { "path": "../../llm/llm" },
+    { "path": "../../llm/token-meter" },
+    { "path": "../../core/session" },
+    { "path": "../../core/agent" },
+    { "path": "../compaction" }
+  ]
+}

+ 2 - 2
packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md
-README.md: 476dfa93129caa867aacd9d7120de58a7ac6700c
-README.zh.md: c6fc5c0ced2da5afd78bfc101f68e7b3c70c107a
+README.md: 346f2fa48e36ce0a116311b61085fc26d0b76dab
+README.zh.md: 32ebcf21097d07e0fd36c273d11839f397e533da

+ 1 - 1
packages/llm/llm-deepseek/README.md

@@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history,
 
 #### Token effect
 
-Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
+Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
 
 #### KV Cache effect
 

+ 1 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
 
 #### Token 影响
 
-提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-llm-retry` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。
+提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。
 
 #### KV Cache 影响
 

+ 2 - 2
packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md
-README.md: 326028b8c2f64dec2a49241752e2285308031de8
-README.zh.md: 502390b50de611f434e561f0567e5fc319fe7550
+README.md: dd2d43fc9f522d1a717ab66eb7c54ceed0f1b5eb
+README.zh.md: b1ef96da154900a60647ce1cd86edba0a4502ef0

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
+The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
 
 #### Token effect
 

+ 1 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -176,7 +176,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无
 
 #### 模型看到什么
 
-所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-retry` 用带标记的副本替换承载节点并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。
+所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。
 
 #### Token 影响
 

+ 2 - 2
packages/llm/llm-retry/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/llm/llm-retry/README.md
-README.md: 52740300adb9e791fb6bcbcd1f4845e684f10667
-README.zh.md: 0855be01fa06e9f2b8194480dcfbb8faf2e54451
+README.md: 0ea815130212a165582540e43aad7d59fad32d2c
+README.zh.md: 43a0187c68d272e2764d34db35fa26e51a9d3a83

+ 4 - 5
packages/llm/llm-retry/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. It also owns one durable repair: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure replaces the surface nodes carrying the count of oldest images the failure names with copies marked `offloaded`, then retries the step outside any provider retry budget.
+`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit.
 
 ## Table of Contents
 
@@ -55,7 +55,7 @@ Each scheduled retry is durable before its wait: the plugin appends a non-surfac
 
 ### Failures and recovery
 
-A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. An `IMAGE_OFFLOAD_REQUIRED` failure carrying `offloadImages` is handled before any policy: the plugin walks the surface in request order, replaces each node carrying one of that many oldest retained image occurrences with a copy whose occurrences are marked `offloaded` (a `surfaceOp: replace` like compaction's), and returns `retry`; when nothing remains to offload it delegates. That retry spends no retry budget and logs no `llm/retry` event, and the retried request sends placeholder text for the marked images ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md)).
+A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages.
 
 -----
 
@@ -77,13 +77,12 @@ The executor is built on one rule: **durable before wait, open-step boundaries.*
 |---|---|
 | [`src/index.ts`](src/index.ts) | The function plugin: waterfall listener, policy lookup, backoff, durable event appends |
 | [`src/history.ts`](src/history.ts) | Durable retry-history lookup from the session log |
-| [`src/image-offload.ts`](src/image-offload.ts) | The surface replacements that offload images for `IMAGE_OFFLOAD_REQUIRED` failures |
 | [`src/types.ts`](src/types.ts) | Browser-safe `llm/retry` and `llm/retry-started` event payload types |
 | [`src/brand.ts`](src/brand.ts) | The `RetryId` brand shared by the event payloads |
 
 ### Recovery flow
 
-A failed step arrives on the waterfall with its provider and resolved policy. An `IMAGE_OFFLOAD_REQUIRED` failure naming `offloadImages` is repaired at once: the carrying surface nodes are replaced and the step retries with no delay. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history.
+A failed step arrives on the waterfall with its provider and resolved policy. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history.
 
 ### Waterfall composition
 
@@ -113,7 +112,7 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages. An `IMAGE_OFFLOAD_REQUIRED` repair is the one exception: the retried request replaces the offloaded images with placeholder text naming each image and its read-only path.
+No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages.
 
 #### Token effect
 

+ 4 - 5
packages/llm/llm-retry/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。它还负责一种持久修复:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 失败时,把承载失败所报数量的最老图片的表层节点替换为标了 `offloaded` 的副本,再重试该步骤,不占任何提供方重试预算。
+`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。
 
 ## 目录
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 
 ### 失败与恢复
 
-在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败在任何策略之前处理:插件按请求顺序遍历表层,把承载那些最老保留图片的每个节点替换为标了 `offloaded` 的副本(和 compaction 一样的 `surfaceOp: replace`),然后返回 `retry`;没有可省略的图片时委派下游。这次重试不占重试预算,也不追加 `llm/retry` 事件,重试的请求为被标记的图片发送占位文本([决定](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md))。
+在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。
 
 -----
 
@@ -77,13 +77,12 @@ kind: "package-reference"
 |---|---|
 | [`src/index.ts`](src/index.ts) | 函数插件:waterfall 监听器、策略查找、退避、持久事件追加 |
 | [`src/history.ts`](src/history.ts) | 从会话日志查找持久重试历史 |
-| [`src/image-offload.ts`](src/image-offload.ts) | 为 `IMAGE_OFFLOAD_REQUIRED` 失败省略图片的表层替换 |
 | [`src/types.ts`](src/types.ts) | 浏览器安全的 `llm/retry` 与 `llm/retry-started` 事件载荷类型 |
 | [`src/brand.ts`](src/brand.ts) | 事件载荷共享的 `RetryId` 品牌 |
 
 ### 恢复流程
 
-失败步骤连同其提供方与解析后的策略一起到达 waterfall。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败会立即修复:替换承载节点,步骤无延迟重试。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。
+失败步骤连同其提供方与解析后的策略一起到达 waterfall。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。
 
 ### Waterfall 组合
 
@@ -113,7 +112,7 @@ kind: "package-reference"
 
 #### 模型看到什么
 
-重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。`IMAGE_OFFLOAD_REQUIRED` 修复是唯一的例外:重试的请求把被省略的图片替换为注明图片及其只读路径的占位文本。
+重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。
 
 #### Token 影响
 

+ 0 - 7
packages/llm/llm-retry/src/index.ts

@@ -10,11 +10,9 @@ import type { Context, Events } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { z as zod } from 'zod'
 import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
-import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm'
 import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import { RetryId } from './brand.ts'
-import { offloadOldestImages } from './image-offload.ts'
 import type { LlmRetryEventData } from './types.ts'
 
 export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts'
@@ -197,11 +195,6 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
     { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters<Events['agent/request-error']>[0],
     next: () => Promise<RequestErrorAction>,
   ): Promise<RequestErrorAction> {
-    // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event.
-    if (failure.code === IMAGE_OFFLOAD_REQUIRED_CODE && failure.offloadImages !== undefined
-      && offloadOldestImages(agent.session, failure.offloadImages)) {
-      return { kind: 'retry' }
-    }
     if (policy === undefined) return next()
     if (policy.mode === 'always') {
       if (signal.aborted || lifetime.signal.aborted) return

+ 2 - 2
packages/llm/llm/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/llm/llm/README.md
-README.md: 398c6e17d5b5da631a48d510844b2156a8f0579d
-README.zh.md: 83293a94d5e5560ef1f5efeee4d4c3db038e9be4
+README.md: a6f20a157fbd1bdeb3293b06739c009b93ab46a3
+README.zh.md: 9b24bfc3e83928929436b5ca19d09902559a6c3e

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/llm/llm/README.md


+ 1 - 1
packages/llm/llm/README.zh.md

@@ -101,7 +101,7 @@ for await (const chunk of ctx.llm.stream({
 
 ### 主流程
 
-请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-llm-retry` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。
+请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-compaction-image-offload` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。
 
 ### 不变式
 

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

@@ -165,8 +165,9 @@ 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-llm-retry`
- * replaces the surface nodes carrying them with copies marked `offloaded` and
- * retries the step, so the model never receives an unlogged projection.
+ * 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.
  */
 export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED'

+ 3 - 3
packages/llm/llm/src/types.ts

@@ -51,9 +51,9 @@ export interface LlmFailure {
   /**
    * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained
    * image occurrences the route needs offloaded before the same request fits
-   * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes
-   * carrying that many oldest retained occurrences with copies marked
-   * `offloaded` and retries the step.
+   * its exact byte accounting. `dsh-compaction-image-offload` replaces the
+   * surface nodes carrying that many oldest retained occurrences with copies
+   * marked `offloaded` and retries the step.
    */
   readonly offloadImages?: number
 }

+ 45 - 5
pnpm-lock.yaml

@@ -1340,6 +1340,9 @@ importers:
       '@deepseek-ai/dsh-compaction-basic':
         specifier: workspace:^
         version: link:../../compaction/compaction-basic
+      '@deepseek-ai/dsh-compaction-image-offload':
+        specifier: workspace:^
+        version: link:../../compaction/compaction-image-offload
       '@deepseek-ai/dsh-compaction-tool-result-pruner':
         specifier: workspace:^
         version: link:../../compaction/compaction-tool-result-pruner
@@ -4605,6 +4608,40 @@ importers:
         specifier: workspace:^
         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:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-agent-loop-testkit':
+        specifier: workspace:^
+        version: link:../../test-support/agent-loop-testkit
+      '@deepseek-ai/dsh-compaction':
+        specifier: workspace:^
+        version: link:../compaction
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@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:
       '@deepseek-ai/dsh-util-values':
@@ -10715,6 +10752,9 @@ importers:
       '@deepseek-ai/dsh-compaction-basic':
         specifier: workspace:^
         version: link:../../packages/compaction/compaction-basic
+      '@deepseek-ai/dsh-compaction-image-offload':
+        specifier: workspace:^
+        version: link:../../packages/compaction/compaction-image-offload
       '@deepseek-ai/dsh-compaction-tool-result-pruner':
         specifier: workspace:^
         version: link:../../packages/compaction/compaction-tool-result-pruner
@@ -17139,13 +17179,13 @@ packages:
   stackback@0.0.2:
     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 
+  standardwebhooks@1.1.1:
+    resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
+
   stat-mode@1.0.0:
     resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
     engines: {node: '>= 6'}
 
-  standardwebhooks@1.1.1:
-    resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
-
   statuses@2.0.2:
     resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
     engines: {node: '>= 0.8'}
@@ -24257,13 +24297,13 @@ snapshots:
 
   stackback@0.0.2: {}
 
-  stat-mode@1.0.0: {}
-
   standardwebhooks@1.1.1:
     dependencies:
       '@stablelib/base64': 1.0.1
       fast-sha256: 1.3.0
 
+  stat-mode@1.0.0: {}
+
   statuses@2.0.2: {}
 
   std-env@4.1.0: {}

+ 1 - 0
python/sdk-runtime/package.json

@@ -31,6 +31,7 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-compaction-basic": "workspace:^",
+    "@deepseek-ai/dsh-compaction-image-offload": "workspace:^",
     "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
     "@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",

+ 3 - 2
snapshots/sdk/inline-image-prompt/session.v2.jsonl

@@ -11,9 +11,10 @@
 {"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[7],"source":{"kind":"fallback"}}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
-{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788839885455,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}}
+{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788851314955,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}}
+{"type":"compaction/prune","data":{"shadowedRange":{"start":7,"end":7},"shadowedSeqs":[7],"shadowedTokenCount":318}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"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}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","start":7,"end":7}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788839885461,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788839885461,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788839885461,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788839885461,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788851314960,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788851314960,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788851314960,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788851314960,"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"}}}

+ 1 - 0
tsconfig.base.json

@@ -279,6 +279,7 @@
       "@deepseek-ai/dsh-compaction": ["./packages/compaction/compaction/src"],
       "@deepseek-ai/dsh-compaction/invariant": ["./packages/compaction/compaction/src/invariant.ts"],
       "@deepseek-ai/dsh-compaction-basic": ["./packages/compaction/compaction-basic/src"],
+      "@deepseek-ai/dsh-compaction-image-offload": ["./packages/compaction/compaction-image-offload/src"],
       "@deepseek-ai/dsh-compaction-tool-result-pruner": ["./packages/compaction/compaction-tool-result-pruner/src"],
       "@deepseek-ai/dsh-credentials": ["./packages/credentials/credentials/src"],
       "@deepseek-ai/dsh-credentials/invariant": ["./packages/credentials/credentials/src/invariant.ts"],

+ 1 - 0
tsconfig.host.json

@@ -269,6 +269,7 @@
     { "path": "./packages/compaction/compaction-basic" },
     { "path": "./packages/compaction/command-compact" },
     { "path": "./packages/compaction/compaction-tool-result-pruner" },
+    { "path": "./packages/compaction/compaction-image-offload" },
     { "path": "./packages/web/web" },
     { "path": "./packages/web/web-search-exa" },
     { "path": "./packages/web/web-search-perplexity" },

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio