소스 검색

Merge commit '806f0f1ae7af106f12237bbd56cfe6f16b79cecb' into codex/subprocess-win32-process-primitives

pku-xht 1 개월 전
부모
커밋
051851ac60
51개의 변경된 파일과 1432개의 추가작업 그리고 247개의 파일을 삭제
  1. 3 0
      .agents/notes/archived/manifest.json
  2. 2 2
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml
  3. 1 0
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md
  4. 1 0
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md
  5. 2 2
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
  6. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
  7. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
  8. 2 2
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml
  9. 2 1
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md
  10. 2 1
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md
  11. 2 2
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
  12. 4 4
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
  13. 4 4
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md
  14. 6 0
      .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml
  15. 34 0
      .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md
  16. 34 0
      .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md
  17. 1 1
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  18. 2 2
      docs/config-catalog.i18n.yaml
  19. 6 2
      docs/config-catalog.md
  20. 6 2
      docs/config-catalog.zh.md
  21. 2 2
      docs/module-graph.i18n.yaml
  22. 2 1
      docs/module-graph.md
  23. 2 1
      docs/module-graph.zh.md
  24. 4 2
      examples/acp-agent/code-mode-image.cordis.snapshot.yml
  25. 7 5
      examples/acp-agent/image.cordis.snapshot.yml
  26. 2 1
      examples/acp-agent/image.cordis.yml
  27. 80 20
      examples/acp-agent/tests/acp.snapshot.ts
  28. 14 19
      examples/acp-agent/tests/fixtures/image-offload.cordis.yml
  29. 4 4
      examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl
  30. 1 1
      examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md
  31. 3 3
      examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl
  32. 4 4
      examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl
  33. 4 4
      examples/acp-agent/tests/snapshots/read-image/session.jsonl
  34. 24 0
      examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md
  35. 17 8
      packages/client/ui-model-selection/src/client/ModelSelect.module.css
  36. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  37. 18 7
      packages/llm/llm-deepseek/README.md
  38. 18 7
      packages/llm/llm-deepseek/README.zh.md
  39. 2 0
      packages/llm/llm-deepseek/package.json
  40. 44 8
      packages/llm/llm-deepseek/src/adapter.ts
  41. 33 2
      packages/llm/llm-deepseek/src/index.ts
  42. 209 24
      packages/llm/llm-deepseek/src/serialize.ts
  43. 17 2
      packages/llm/llm-deepseek/src/types.ts
  44. 166 5
      packages/llm/llm-deepseek/tests/adapter.spec.ts
  45. 73 3
      packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
  46. 310 3
      packages/llm/llm-deepseek/tests/serialize.spec.ts
  47. 21 77
      packages/llm/llm-pi-ai/src/context.ts
  48. 63 5
      packages/llm/llm-pi-ai/tests/context.spec.ts
  49. 78 0
      packages/llm/llm/src/content.ts
  50. 89 0
      packages/llm/llm/tests/content.spec.ts
  51. 3 0
      pnpm-lock.yaml

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -334,6 +334,9 @@
     "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee",
     "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec",
     "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1",
+    "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a",
+    "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b",
+    "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976",
     "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db",
     "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67",
     "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c",

+ 2 - 2
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.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/simplification/2026-07-04-drop-image-content-block.md
-2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0
-2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9
+2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6
+2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c

+ 1 - 0
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md

@@ -1,6 +1,7 @@
 # Agent Note: Drop the `image` content block until a path can honor it
 
 Status: implemented
+Archived: 2026-08-19
 
 English | [中文](2026-07-04-drop-image-content-block.zh.md)
 

+ 1 - 0
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它
 
 Status: implemented
+Archived: 2026-08-19
 
 [English](2026-07-04-drop-image-content-block.md) | 中文
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.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-06-11-content-block-vocabulary.md
-2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022
-2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600
+2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b
+2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md

@@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall
 ## Consequences
 
 - Reasoning has a core home without provider-specific shapes.
-- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md).
+- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md).
 - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes.
 - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests.
 - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost.

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md

@@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
 ## 后果
 
 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。
-- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。
+- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。
 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。
 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。
 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.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/bug-fix/2026-08-18-request-image-payload-bound.md
-2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1
-2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385
+2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1
+2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md

@@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the
 
 ## Decision
 
-The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route.
+The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route.
 
 ## Offload is conversion, not history
 
@@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit
 ## Related
 
 - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size).
+- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route.
 
 ## Consequences
 

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md

@@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型
 
 ## Decision
 
-pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。
+pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。
 
 ## offload 是转换而非历史
 
@@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE
 ## Related
 
 - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。
+- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。
 
 ## Consequences
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
-2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9
-2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7
+2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5
+2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0

+ 4 - 4
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md

@@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments
 
 Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history.
 
-This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
+This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
 
 The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle.
 
@@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations
 
 The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast.
 
-The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
+Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`.
 
 Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
 
@@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc
 | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. |
 | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. |
 | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. |
-| `packages/llm/llm-deepseek` | Reject image content explicitly. |
+| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. |
 | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. |
 | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. |
 | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. |
@@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability
 
 ### Implementation
 
-The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
+The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
 
 No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
 

+ 4 - 4
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。
 
-这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
+这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
 
 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。
 
@@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都
 
 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。
 
-Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
+Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。
 
 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
 
@@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 |
 | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
 | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
-| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
+| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 |
 | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 |
 | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 |
 | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 |
@@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 
 ### 实现
 
-已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
+已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
 
 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.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 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md
+2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04
+2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc

+ 34 - 0
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md

@@ -0,0 +1,34 @@
+# Agent Note: Direct DeepSeek vision input
+
+Status: implemented
+
+English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md)
+
+## Problem
+
+DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider.
+
+## Decision
+
+The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently.
+
+The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O.
+
+The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`.
+
+Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported.
+
+## Alternatives considered
+
+- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id.
+- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata.
+- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order.
+- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns.
+
+## Verification
+
+Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder.
+
+## Consequences
+
+Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available.

+ 34 - 0
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md

@@ -0,0 +1,34 @@
+# Agent Note: 直接 DeepSeek 视觉输入
+
+Status: implemented
+
+[English](2026-08-19-direct-deepseek-vision-input.md) | 中文
+
+## Problem
+
+DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。
+
+## Decision
+
+直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。
+
+适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。
+
+直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。
+
+规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。
+
+## Alternatives considered
+
+- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。
+- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。
+- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。
+- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。
+
+## Verification
+
+包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。
+
+## Consequences
+
+已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。

+ 1 - 1
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -224,7 +224,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect(document).toContain('name: Private Preview')
     expect(document).toContain('contextWindow: 131072')
     expect(document).toContain('maxTokens: 64000')
-    expect(document).not.toContain('id: deepseek-v4-flash')
+    expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m)
 
     await page.keyboard.press('Escape')
     // A connected Workspace is what puts a live composer — and its model

+ 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: 7cfd1c2f12b6478bec14b2d4a731475bd6e5bda0
-config-catalog.zh.md: dda0aa8e994fb28b82d8eafdd402160189aaf94d
+config-catalog.md: d4522ffd5681e7cc4260193b9a08648d28d34912
+config-catalog.zh.md: b6acb179782fb97176d49d989791bb70f2372c8f

+ 6 - 2
docs/config-catalog.md

@@ -870,6 +870,8 @@ export interface Config {
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
+  /** Maximum accumulated base64 image payload per request (default 20 MiB). */
+  maxRequestImageBytes?: number
   /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
   retryPolicy?: RetryPolicyConfig
 }
@@ -886,12 +888,14 @@ export interface DeepSeekCatalogModel {
   contextWindow?: number
   /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
   maxTokens?: number
+  /** Accepted request modalities; omission is text-only. */
+  inputModalities?: ModelModality[]
 }
 ```
 
-Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
+Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 6 - 2
docs/config-catalog.zh.md

@@ -872,6 +872,8 @@ export interface Config {
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
+  /** Maximum accumulated base64 image payload per request (default 20 MiB). */
+  maxRequestImageBytes?: number
   /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
   retryPolicy?: RetryPolicyConfig
 }
@@ -888,12 +890,14 @@ export interface DeepSeekCatalogModel {
   contextWindow?: number
   /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
   maxTokens?: number
+  /** Accepted request modalities; omission is text-only. */
+  inputModalities?: ModelModality[]
 }
 ```
 
-依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
+依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts)
+来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 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: b1edd1c047cbf9b380d86d12f217db882f07d369
-module-graph.zh.md: 3a096b3189cd543da68db48aa0731484d838a690
+module-graph.md: 95304807038c2e0a3e57eab09697f4cfe61bc0b8
+module-graph.zh.md: 68a04eea607eb1261332aa674501001c8e93d328

+ 2 - 1
docs/module-graph.md

@@ -409,6 +409,7 @@ flowchart TD
   pkg_settings_file --> pkg_invariants
   pkg_settings_file --> pkg_settings
   pkg_llm_deepseek --> pkg_anonymous_user_id
+  pkg_llm_deepseek --> pkg_attachment
   pkg_llm_deepseek --> pkg_credentials
   pkg_llm_deepseek --> pkg_invariants
   pkg_llm_deepseek --> pkg_launch_environment
@@ -1468,7 +1469,7 @@ flowchart TD
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 2 - 1
docs/module-graph.zh.md

@@ -411,6 +411,7 @@ flowchart TD
   pkg_settings_file --> pkg_invariants
   pkg_settings_file --> pkg_settings
   pkg_llm_deepseek --> pkg_anonymous_user_id
+  pkg_llm_deepseek --> pkg_attachment
   pkg_llm_deepseek --> pkg_credentials
   pkg_llm_deepseek --> pkg_invariants
   pkg_llm_deepseek --> pkg_launch_environment
@@ -1470,7 +1471,7 @@ flowchart TD
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 4 - 2
examples/acp-agent/code-mode-image.cordis.snapshot.yml

@@ -13,7 +13,7 @@
         name: '@deepseek-ai/dsh-acp-demo'
         config:
           provider: deepseek-official
-          model: deepseek-v4-flash
+          model: deepseek-v4-flash-vision-exp
           persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
           persistenceCompression: none
           workspaceContext:
@@ -37,6 +37,8 @@
                   name: DeepSeek
                   models:
                     - id: deepseek-v4-flash
-                      inputModalities: [text, image]
+                      inputModalities: [text]
                     - id: deepseek-v4-pro
                       inputModalities: [text]
+                    - id: deepseek-v4-flash-vision-exp
+                      inputModalities: [text, image]

+ 7 - 5
examples/acp-agent/image.cordis.snapshot.yml

@@ -1,9 +1,9 @@
 # Keyless replay for the read-image success scenario. Include patches cannot
 # target entries behind a nested include, so this restates the replay overlay
 # directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and
-# re-pins the recorded flash model. The replay catalog declares image input on
-# flash, so the strict read_image gate accepts the route and the tool result
-# carries the durable image block; the live DeepSeek route cannot record this.
+# re-pins the recorded vision model. The replay catalog declares image input,
+# so the strict read_image gate accepts the route and the tool result carries
+# the durable image block.
 - id: base
   name: '@deepseek-ai/cordis-plugin-include'
   config:
@@ -16,7 +16,7 @@
         name: '@deepseek-ai/dsh-acp-demo'
         config:
           provider: deepseek-official
-          model: deepseek-v4-flash
+          model: deepseek-v4-flash-vision-exp
           persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
           persistenceCompression: none
           workspaceContext:
@@ -36,6 +36,8 @@
                   name: DeepSeek
                   models:
                     - id: deepseek-v4-flash
-                      inputModalities: [text, image]
+                      inputModalities: [text]
                     - id: deepseek-v4-pro
                       inputModalities: [text]
+                    - id: deepseek-v4-flash-vision-exp
+                      inputModalities: [text, image]

+ 2 - 1
examples/acp-agent/image.cordis.yml

@@ -3,7 +3,8 @@
 # snapshot harness scopes per run, so the overlay itself carries no paths. The
 # app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the
 # authored fixture and the pinned header class are flash), because a config
-# patch replaces the whole app config.
+# patch replaces the whole app config. Keyless replay supplies its own exact
+# image-capable catalog while the production endpoint remains unavailable.
 - id: base
   name: '@deepseek-ai/cordis-plugin-include'
   config:

+ 80 - 20
examples/acp-agent/tests/acp.snapshot.ts

@@ -16,6 +16,7 @@ import {
 } from '@deepseek-ai/dsh-acp-snapshot'
 import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
 import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
+import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm'
 
 /**
  * The acp-agent example's snapshot suite: the scenario table for
@@ -222,27 +223,26 @@ const SCENARIOS: Scenario[] = [
     posixOnly: true,
   },
   // Authored keyless replays through the assembled app: the replay catalog
-  // declares flash image-capable (success) or text-only (refusal), and the
+  // declares the vision model image-capable and Flash text-only, and the
   // real read_image tool executes against the workspace fixture and the real
-  // attachment store. Both boot the same composed header (the tool registers
-  // with the attachment store, independent of route), so they share one class.
+  // attachment store. The success route selects the vision model while the
+  // refusal route retains text-only Flash, so each pins its exact header.
   {
     name: 'read-image',
     hasModelTurn: true,
     recorded: false,
     pinsHeader: true,
     headerClass: 'image',
-    // The overlay adds no prompt section (read_image carries no guidance), so
-    // the composed system prompt is byte-identical to the default class; only
-    // the tool-schema sidecar is class-specific.
-    systemPromptSource: 'text-turn',
     configPath: IMAGE_CONFIG,
   },
   {
     name: 'read-image-text-route',
     hasModelTurn: true,
     recorded: false,
-    headerClass: 'image',
+    pinsHeader: true,
+    headerClass: 'image-text-route',
+    systemPromptSource: 'text-turn',
+    toolSchemasSource: 'read-image',
     configPath: IMAGE_TEXT_ROUTE_CONFIG,
   },
   // Authored keyless replay of the oversized-image refusal: admission rejects
@@ -688,7 +688,7 @@ defineAcpSnapshotSuite({
   hasPwsh,
 })
 
-it('pins pi-ai image offload in the request sent by the assembled app', async () => {
+it('pins native DeepSeek image offload in the request sent by the assembled app', async () => {
   const requests: Record<string, unknown>[] = []
   const server = createServer((request: IncomingMessage, response: ServerResponse) => {
     let body = ''
@@ -697,13 +697,21 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
     request.on('end', () => {
       requests.push(JSON.parse(body) as Record<string, unknown>)
       response.writeHead(200, { 'content-type': 'text/event-stream' })
-      response.end([
-        'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
-        'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
-        'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
-        'data: [DONE]',
-        '',
-      ].join('\n\n'))
+      const events = requests.length === 1
+        ? [
+          'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}',
+          'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+          'data: [DONE]',
+          '',
+        ]
+        : [
+          'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
+          'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
+          'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+          'data: [DONE]',
+          '',
+        ]
+      response.end(events.join('\n\n'))
     })
   })
   await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
@@ -722,7 +730,7 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
           { type: 'image', data: image, mimeType: 'image/png' },
           { type: 'text', text: ' with the newer image ' },
           { type: 'image', data: image, mimeType: 'image/png' },
-          { type: 'text', text: ', then reply with DONE.' },
+          { type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
         ],
       },
     ],
@@ -734,13 +742,14 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
       mode: 'record',
       configPath: IMAGE_OFFLOAD_CONFIG,
       fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
+      workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'),
       env: {
         DSH_SNAPSHOT_API_KEY: 'snapshot-key',
-        DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`,
+        DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
       },
     })
     expect(result.stderr).toBe('')
-    expect(requests).toHaveLength(1)
+    expect(requests).toHaveLength(2)
     const messages = requests[0]?.messages as { content?: unknown }[] | undefined
     const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted'))
     expect(offloaded?.content).toMatchInlineSnapshot(`
@@ -764,11 +773,62 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
           "type": "image_url",
         },
         {
-          "text": ", then reply with DONE.",
+          "text": ", then use read_image on red.png and reply with DONE.",
           "type": "text",
         },
       ]
     `)
+
+    const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{
+      role?: unknown
+      content?: unknown
+    }>
+    const toolMessage = followup.find(message => message.role === 'tool')
+    if (toolMessage === undefined || typeof toolMessage.content !== 'string') {
+      throw new Error('native read_image request has no tool content')
+    }
+    const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => (
+      cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`]
+    )))]
+    let toolContent = toolMessage.content
+    for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}')
+    toolMessage.content = toolContent
+    expect(followup).toEqual([
+      {
+        role: 'user',
+        content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`,
+      },
+      {
+        role: 'user',
+        content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n'
+          + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n'
+          + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).',
+      },
+      {
+        role: 'assistant',
+        content: '',
+        tool_calls: [{
+          id: 'native-read-image',
+          type: 'function',
+          function: { name: 'read_image', arguments: '{"file_path":"red.png"}' },
+        }],
+      },
+      {
+        role: 'tool',
+        tool_call_id: 'native-read-image',
+        content: '<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>',
+      },
+      {
+        role: 'user',
+        content: [
+          { type: 'text', text: 'Attached image(s) from tool result:' },
+          {
+            type: 'image_url',
+            image_url: { url: `data:image/png;base64,${image}` },
+          },
+        ],
+      },
+    ])
   } finally {
     await new Promise<void>(resolve => server.close(() => { resolve() }))
   }

+ 14 - 19
examples/acp-agent/tests/fixtures/image-offload.cordis.yml

@@ -1,5 +1,5 @@
-# Keyless assembled-request snapshot for pi-ai image offload. The local
-# provider endpoint is supplied by the snapshot test; the real attachment
+# Keyless assembled-request snapshot for native DeepSeek image offload. The
+# local provider endpoint is supplied by the snapshot test; the real attachment
 # store and ACP bridge carry two uploaded images into one model request.
 - id: base
   name: '@deepseek-ai/cordis-plugin-include'
@@ -8,12 +8,21 @@
     patches:
       - id: llm-deepseek
         name: '@deepseek-ai/dsh-llm-deepseek'
-        disabled: true
+        config:
+          apiKeyEnv: DSH_SNAPSHOT_API_KEY
+          baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
+          thinking: disabled
+          maxRequestImageBytes: 92
+          models:
+            - id: deepseek-v4-flash-vision-exp
+              contextWindow: 32768
+              maxTokens: 1024
+              inputModalities: [text, image]
       - id: acp-agent
         name: '@deepseek-ai/dsh-acp-demo'
         config:
-          provider: snapshot
-          model: vision
+          provider: deepseek-official
+          model: deepseek-v4-flash-vision-exp
           persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
           persistenceCompression: none
           workspaceContext:
@@ -25,17 +34,3 @@
       - insert:
           - id: attachment-local
             name: '@deepseek-ai/dsh-attachment-local'
-          - id: llm-pi-ai
-            name: '@deepseek-ai/dsh-llm-pi-ai'
-            config:
-              providers:
-                snapshot:
-                  apiKeyEnv: DSH_SNAPSHOT_API_KEY
-                  api: openai-completions
-                  baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
-                  maxRequestImageBytes: 92
-                  models:
-                    - id: vision
-                      contextWindow: 32768
-                      maxTokens: 1024
-                      input: [text, image]

+ 4 - 4
examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl

@@ -6,13 +6,13 @@
 {"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1786431644558,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}}
 {"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"ef352c42-b661-4b71-8c6a-7dbbd0a9f591"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}
 {"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}}
 {"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}}
@@ -28,6 +28,6 @@
 {"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"}
+{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a721cef2-2c49-4336-8d07-5f6cc15f4b67"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"}
 {"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 1 - 1
examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md

@@ -1,6 +1,6 @@
 You are an AI agent powered by DeepSeek Harness.
 
-You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
+You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}.
 
 Verify your work by running the code or tests. Keep answers brief and factual.
 

+ 3 - 3
examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl

@@ -6,12 +6,12 @@
 {"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"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":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1783952000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1783952000009,"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":"e58e49ab-9c34-4ba0-9276-9429b32c5ea0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}}
 {"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 4
examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl

@@ -6,13 +6,13 @@
 {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}}
 {"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}
 {"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}}
@@ -21,6 +21,6 @@
 {"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}}
 {"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
+{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
 {"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 4
examples/acp-agent/tests/snapshots/read-image/session.jsonl

@@ -6,13 +6,13 @@
 {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1786344283033,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}}
 {"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}
 {"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}}
@@ -21,6 +21,6 @@
 {"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
+{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
 {"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 24 - 0
examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md

@@ -0,0 +1,24 @@
+You are an AI agent powered by DeepSeek Harness.
+
+You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+Verify your work by running the code or tests. Keep answers brief and factual.
+
+
+Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
+
+Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.
+
+Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.
+
+Check the [exit code: N] marker on every bash result; investigate failures before moving on.
+
+Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
+
+Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
+
+Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
+Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.

+ 17 - 8
packages/client/ui-model-selection/src/client/ModelSelect.module.css

@@ -11,7 +11,10 @@
   align-items: center;
   gap: 4px;
   min-width: 0;
+  /* Legacy fallback, then a row-relative cap that grants long names more
+     room without letting this trailing control consume the composer. */
   max-width: 220px;
+  max-width: min(360px, 45cqw);
   height: 28px;
   padding: 0 4px 0 8px;
   border: none;
@@ -69,7 +72,11 @@
   z-index: 20;
   display: flex;
   flex-direction: column;
-  width: min(240px, calc(100vw - 32px));
+  /* Let the visible pane's rows set the card width, but keep pathological ids
+     and narrow screens inside a bounded card where ellipsis remains. */
+  width: max-content;
+  min-width: min(240px, calc(100vw - 32px));
+  max-width: min(420px, calc(100vw - 32px));
   max-height: min(360px, calc(100vh - 96px));
   overflow: hidden;
   padding: 4px;
@@ -150,10 +157,12 @@
 }
 
 .option {
+  box-sizing: border-box;
   display: flex;
   align-items: center;
   gap: 8px;
-  width: 100%;
+  width: auto;
+  min-width: 100%;
   min-height: 38px;
   padding: 6px 8px;
   border: none;
@@ -218,10 +227,12 @@
    padding, 8px gap, 10px radius; 14/22 label in primary, value in the
    #81858C tertiary tone, right chevron drilling into the sub-list. */
 .cell {
+  box-sizing: border-box;
   display: flex;
   align-items: center;
   gap: 8px;
-  width: 100%;
+  width: auto;
+  min-width: 100%;
   height: 40px;
   padding: 0 10px;
   border: none;
@@ -239,19 +250,17 @@
 }
 
 .cellLabel {
-  flex: 1 1 auto;
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
+  flex: 0 0 auto;
   white-space: nowrap;
 }
 
 .cellValue {
-  flex: 0 1 auto;
+  flex: 1 1 auto;
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  text-align: right;
   color: var(--dsw-alias-label-tertiary);
 }
 

+ 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: 9bb28e6876b82c521341769123a8b2d0e5d98e09
-README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00
+README.md: 599dc1f530884df58fcc64d8cdf6c62fac80bc1f
+README.zh.md: b1a45a1f398fdbb11cddebe34b646edf37f579be

+ 18 - 7
packages/llm/llm-deepseek/README.md

@@ -20,6 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
     reasoningEffort: high    # optional; off | low | high | max — omitted ⇒ high
     maxTokens: 256000        # optional positive per-request output cap; this is the default
     streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
+    maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default
     retryPolicy:             # optional; omission uses normal mode with five retries
       mode: always           # normal | always
       backoff:
@@ -30,12 +31,19 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
     models:                  # optional; defaults to V4 Flash and V4 Pro
       - id: deepseek-v4-flash
         name: DeepSeek-V4-Flash
+      - id: private-vision
+        name: Private Vision
+        inputModalities: [text, image]
       - id: private-reasoner
         description: Company-hosted reasoning model
         contextWindow: 512000
 ```
 
-The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
+The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Vision models are not advertised by default until their endpoint rollout is complete, but a deployment can add one with `inputModalities: [text, image]`. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only.
+
+An image-capable catalog entry may declare `inputModalities: [text, image]`. The adapter resolves user and tool-result `ImageBlock` references through `ctx.attachments`, verifies the stored bytes, and sends transient `data:<media-type>;base64,...` `image_url` parts without changing the durable session message. Text-only and unlisted models reject image input before credential, attachment, or network I/O. System and assistant history remain image-free; tool-result images follow their string-only `tool` messages in a separate `user` message.
+
+`maxRequestImageBytes` bounds accumulated base64 image payload and defaults to 20 MiB, leaving headroom below the official 30 MiB request-body limit for text, tools, and JSON framing. When history exceeds the bound, the oldest images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]` until the request fits; omitted attachments are not read. Attachment admission continues to own per-image and per-message raw-byte, media, dimension, and pixel limits.
 
 `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`.
 
@@ -49,10 +57,11 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo
 
 ## Dynamic configuration (settings + credentials)
 
-Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
+Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, image bound, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Three optional seams feed that thunk:
 
 - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
 - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
+- **`ctx.attachments`** — image requests resolve this service at request time, so Cordis load order does not freeze optional image availability. Absence rejects image input with `UNSUPPORTED_CONTENT`; text-only calls do not require the service.
 
 The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
 
@@ -70,11 +79,12 @@ DeepSeek request identity is separate from app attribution. After credential res
 - The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`.
 - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
 - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
+- Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`.
 - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
 
 ## Errors
 
-Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy).
+Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s and 413), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. Attachment reads retain their stable attachment failure code rather than becoming transport failures. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy).
 
 ## Model Experience
 
@@ -82,15 +92,15 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
 
 #### What the model sees
 
-The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
+The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
 
 #### Token effect
 
-Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
+Provider tokenization governs exact text and image-token input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning and over-budget images avoids paying those tokens again; cache-read usage is reported when available.
 
 #### KV Cache effect
 
-An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips.
+An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends during tool round trips.
 
 ### DeepSeek response
 
@@ -111,4 +121,5 @@ Loop-retained response blocks append to the next request and preserve its earlie
 - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
 - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
 - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
-- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.
+- **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`.
+- **Images are input-only durable attachments** — direct external URLs, the Files API, and assistant image output are not supported.

+ 18 - 7
packages/llm/llm-deepseek/README.zh.md

@@ -20,6 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
     reasoningEffort: high    # optional; off | low | high | max — omitted ⇒ high
     maxTokens: 256000        # optional positive per-request output cap; this is the default
     streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
+    maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default
     retryPolicy:             # optional; omission uses normal mode with five retries
       mode: always           # normal | always
       backoff:
@@ -30,12 +31,19 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
     models:                  # optional; defaults to V4 Flash and V4 Pro
       - id: deepseek-v4-flash
         name: DeepSeek-V4-Flash
+      - id: private-vision
+        name: Private Vision
+        inputModalities: [text, image]
       - id: private-reasoner
         description: Company-hosted reasoning model
         contextWindow: 512000
 ```
 
-该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
+该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。在视觉模型端点完成发布前,默认目录不会公布视觉模型,但部署方可以通过 `inputModalities: [text, image]` 主动添加。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。
+
+支持图片的 catalog 配置项可以声明 `inputModalities: [text, image]`。适配器通过 `ctx.attachments` 解析 user 和工具结果中的 `ImageBlock` 引用,校验已存储字节,再发送瞬态 `data:<media-type>;base64,...` `image_url` 部分,不改变持久会话消息。纯文本模型与未列出模型会在凭据、附件或网络 I/O 前拒绝图片输入。System 和 assistant 历史仍不能包含图片;工具结果图片会在仅含字符串的 `tool` 消息后,通过单独的 `user` 消息发送。
+
+`maxRequestImageBytes` 限制累计 base64 图片 payload,默认值为 20 MiB,为官方 30 MiB 请求正文限制中的文本、工具和 JSON 分帧保留余量。历史超过上限时,适配器会从最旧图片开始替换为固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`,直至请求可容纳;被省略的附件不会被读取。附件准入仍负责单图和单消息原始字节数、媒体类型、尺寸与像素限制。
 
 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
 
@@ -49,10 +57,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
 
 ## 动态配置(settings + credentials)
 
-连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
+连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值、图片上限与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。三个可选 seam 供给该 thunk:
 
 - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
+- **`ctx.attachments`**——图片请求会在请求时解析该服务,因此 Cordis 加载顺序不会冻结可选图片能力。服务缺失时,图片输入以 `UNSUPPORTED_CONTENT` 失败;纯文本调用不依赖该服务。
 
 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
 
@@ -70,11 +79,12 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提
 - 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。
 - 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。
 - **推理回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(思考模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。
+- 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。
 - Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。
 
 ## 错误
 
-非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_<status>`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。
+非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_<status>`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。
 
 ## 模型体验
 
@@ -82,15 +92,15 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提
 
 #### 模型看到的内容
 
-所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。
+所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。
 
 #### Token 影响
 
-精确输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理则避免再次支付这些 token;可用时会报告 cache-read 用量。
+精确文本与图片 token 输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理和超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。
 
 #### KV Cache 影响
 
-未更改的已组装前缀可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀或历史变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。
+未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。
 
 ### DeepSeek 响应
 
@@ -111,4 +121,5 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
-- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。
+- **会跳过插件添加的内容块类型**:核心文本与支持的图片块会被序列化,空工具输出会以字面 `(no output)` 通过协议发送。
+- **图片是仅输入的持久附件**:不支持直接外部 URL、Files API 和 assistant 图片输出。

+ 2 - 0
packages/llm/llm-deepseek/package.json

@@ -32,6 +32,7 @@
   ],
   "license": "MIT",
   "peerDependencies": {
+    "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
@@ -46,6 +47,7 @@
     "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 44 - 8
packages/llm/llm-deepseek/src/adapter.ts

@@ -8,19 +8,21 @@
  * @module dsh-llm-deepseek/adapter
  */
 
-import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type {
   GenerateOptions,
   LlmModelInfo,
   LlmProviderInfo,
   LlmResolvedModelInfo,
+  ModelModality,
   ResolvedRetryPolicy,
   StreamChunk,
 } from '@deepseek-ai/dsh-llm'
+import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
 import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
 import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
-import { serializeRequest } from './serialize.ts'
+import { serializeRequest, serializeRequestWithImages } from './serialize.ts'
 import type { RequestDefaults } from './serialize.ts'
 import { parseSse } from './sse.ts'
 import { translate } from './translate.ts'
@@ -38,6 +40,8 @@ export interface DeepSeekCatalogModel {
   contextWindow?: number
   /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
   maxTokens?: number
+  /** Accepted request modalities; omission is text-only. */
+  inputModalities?: ModelModality[]
 }
 
 /**
@@ -66,6 +70,8 @@ export interface DeepSeekConnectionOptions {
   models: readonly DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding. */
   streamIdleTimeoutMs: number
+  /** Maximum accumulated base64 image payload in one request. */
+  maxRequestImageBytes: number
   /** Provider-owned model-request retry policy, already resolved. */
   retryPolicy: ResolvedRetryPolicy
 }
@@ -83,6 +89,8 @@ export interface DeepSeekAdapterOptions {
   resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
   /** Resolve the harness-home anonymous id shared with telemetry and feedback. */
   resolveUserId: () => AnonymousUserId
+  /** Resolve the current durable attachment service; absence rejects image input. */
+  resolveAttachments?: () => AttachmentStore | undefined
 }
 
 /** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -91,6 +99,8 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
 export const DEFAULT_CONTEXT_WINDOW = 1_000_000
 /** Default per-request output-token cap. */
 export const DEFAULT_MAX_TOKENS = 256_000
+/** Default bound on accumulated base64 image payload per request. */
+export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
 const OFF_REASONING_EFFORT = ReasoningEffortId('off')
 const LOW_REASONING_EFFORT = ReasoningEffortId('low')
@@ -112,7 +122,7 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo
     id: model.id,
     name: model.name ?? model.id,
     ...model.description === undefined ? {} : { description: model.description },
-    inputModalities: ['text'],
+    inputModalities: model.inputModalities ?? ['text'],
   }
 }
 
@@ -139,6 +149,7 @@ function requestId(headers: Headers): ReturnType<typeof ProviderRequestId> | und
  */
 export function httpErrorCode(status: number, error?: WireError['error']): string {
   if (status === 401 || status === 403) return 'AUTH'
+  if (status === 413) return 'INVALID_REQUEST'
   const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
   if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
   if (status === 429) return 'RATE_LIMIT'
@@ -184,10 +195,9 @@ export class DeepSeekAdapter extends LlmAdapter {
     const contextWindow = configured?.contextWindow
       ?? connection.defaultContextWindow
     return Promise.resolve({
-      // The chat-completions wire route is text-only regardless of catalog
-      // membership, so the uncatalogued fallback declares the same negative
-      // capability — "unknown" here would let the host accept and persist
-      // images the serializer must then reject.
+      // An uncatalogued endpoint is safely treated as text-only. Declaring an
+      // unverified image capability would let the host persist input that the
+      // endpoint may reject on every later turn.
       ...configured === undefined
         ? { provider, id: model, name: model, inputModalities: ['text' as const] }
         : modelInfo(provider, configured),
@@ -222,6 +232,24 @@ export class DeepSeekAdapter extends LlmAdapter {
     // The key resolves *from this snapshot*, so an endpoint and the secret
     // sent to it can never come from different configuration generations.
     const connection = this.config.options()
+    const hasImages = options.messages.some(message => contentHasImage(message.content))
+    let attachments: AttachmentStore | undefined
+    if (hasImages) {
+      const model = connection.models.find(entry => entry.id === options.model)
+      if (model?.inputModalities?.includes('image') !== true) {
+        throw new LlmError(
+          `DeepSeek model "${options.model}" does not accept image input.`,
+          'UNSUPPORTED_CONTENT',
+        )
+      }
+      attachments = this.config.resolveAttachments?.()
+      if (attachments === undefined) {
+        throw new LlmError(
+          'DeepSeek image conversion requires the durable attachment service.',
+          'UNSUPPORTED_CONTENT',
+        )
+      }
+    }
     const apiKey = await this.config.resolveApiKey(connection)
     const userId = this.config.resolveUserId()
     const consumer = new AbortController()
@@ -235,6 +263,7 @@ export class DeepSeekAdapter extends LlmAdapter {
       connection,
       apiKey,
       userId,
+      attachments,
       () => { watchdog.pulse() },
     )[Symbol.asyncIterator]()
     let exhausted = false
@@ -278,9 +307,16 @@ export class DeepSeekAdapter extends LlmAdapter {
     connection: DeepSeekConnectionOptions,
     apiKey: string,
     userId: AnonymousUserId,
+    attachments: AttachmentStore | undefined,
     onComment: () => void,
   ): AsyncIterable<StreamChunk> {
-    const body = serializeRequest(options, connection.defaults)
+    const body = attachments === undefined
+      ? serializeRequest(options, connection.defaults)
+      : await serializeRequestWithImages(options, {
+        attachments,
+        maxRequestImageBytes: connection.maxRequestImageBytes,
+        signal,
+      }, connection.defaults)
     // Prepared outside the try so the TRANSPORT label below covers exactly the
     // transport boundary, never a serialization failure.
     const payload = JSON.stringify(body)

+ 33 - 2
packages/llm/llm-deepseek/src/index.ts

@@ -14,7 +14,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
-import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
+import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -22,6 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
 import {
   DEFAULT_CONTEXT_WINDOW,
+  DEFAULT_MAX_REQUEST_IMAGE_BYTES,
   DEFAULT_MAX_TOKENS,
   DEFAULT_STREAM_IDLE_TIMEOUT_MS,
   DeepSeekAdapter,
@@ -30,6 +31,7 @@ import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.
 
 export {
   DEFAULT_CONTEXT_WINDOW,
+  DEFAULT_MAX_REQUEST_IMAGE_BYTES,
   DEFAULT_MAX_TOKENS,
   DEFAULT_STREAM_IDLE_TIMEOUT_MS,
   DeepSeekAdapter,
@@ -51,6 +53,8 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
   { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW },
 ]
 
+const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]
+
 /**
  * Plugin config, validated by the same-named schemastery schema and doubling
  * as the `llm-deepseek` settings-section shape. Every field is optional in
@@ -76,6 +80,8 @@ export interface Config {
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
+  /** Maximum accumulated base64 image payload per request (default 20 MiB). */
+  maxRequestImageBytes?: number
   /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
   retryPolicy?: RetryPolicyConfig
 }
@@ -86,6 +92,7 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
   description: z.string(),
   contextWindow: z.number().step(1).min(1),
   maxTokens: z.number().step(1).min(1),
+  inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']),
 })
 
 export const Config: z<Config> = z.object({
@@ -97,6 +104,7 @@ export const Config: z<Config> = z.object({
   defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
   models: z.array(catalogModel).default(DEFAULT_MODELS),
   streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
+  maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
   retryPolicy: RetryPolicySchema,
 })
 
@@ -134,6 +142,18 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
         `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,
       )
     }
+    const inputModalities = model.inputModalities ?? ['text']
+    if (inputModalities.length === 0) {
+      throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not be empty`)
+    }
+    if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) {
+      throw new Error(
+        `llm-deepseek: catalog model "${model.id}" inputModalities must contain only "text" and "image"`,
+      )
+    }
+    if (new Set(inputModalities).size !== inputModalities.length) {
+      throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`)
+    }
     if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
     seen.add(model.id)
     return {
@@ -142,6 +162,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
       ...model.description === undefined ? {} : { description: model.description },
       ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
       ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
+      inputModalities: [...inputModalities],
     }
   })
 }
@@ -180,6 +201,10 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro
       `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
     )
   }
+  const maxRequestImageBytes = config.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES
+  if (!Number.isSafeInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) {
+    throw new Error('llm-deepseek: maxRequestImageBytes must be a positive safe integer')
+  }
   return {
     apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
     baseURL: config.baseURL
@@ -193,6 +218,7 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro
     defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
     models: resolveModels(config.models),
     streamIdleTimeoutMs,
+    maxRequestImageBytes,
     retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),
   }
 }
@@ -247,7 +273,12 @@ export function apply(ctx: Context, config: Config): void {
 
   let userId: AnonymousUserId | undefined
   const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId()
-  const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })
+  const adapter = new DeepSeekAdapter({
+    options,
+    resolveApiKey,
+    resolveUserId,
+    resolveAttachments: () => ctx.get('attachments'),
+  })
   ctx.llm.registerConfigurableProviders([
     { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
   ])

+ 209 - 24
packages/llm/llm-deepseek/src/serialize.ts

@@ -1,15 +1,22 @@
 /**
- * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text
- * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages.
- * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by
- * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only;
- * unknown declaration-merged block types retain the adapter's documented extension fallback.
+ * Serialize harness messages into DeepSeek chat completions. Text-only
+ * requests retain string user content; the image path resolves durable
+ * attachments into ordered data-URL parts. Tool-result images follow their
+ * string-only tool messages in a separate user message.
  * @module dsh-llm-deepseek/serialize
  */
 
-import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
+import { contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
-import type { WireMessage, WireRequest, WireTool } from './types.ts'
+import { AttachmentError } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
+import type {
+  WireImageContentPart,
+  WireMessage,
+  WireRequest,
+  WireTool,
+  WireUserContentPart,
+} from './types.ts'
 
 /** Adapter-level request defaults (from plugin config). */
 export interface RequestDefaults {
@@ -22,6 +29,18 @@ interface ResolvedThinking {
   reasoningEffort?: 'low' | 'high' | 'max'
 }
 
+/** Dependencies required only when the request contains image input. */
+export interface ImageSerializationOptions {
+  /** Durable resolver for canonical image references. */
+  attachments: AttachmentStore
+  /** Positive bound on accumulated base64 image payload. */
+  maxRequestImageBytes: number
+  /** Cancellation shared with the provider request. */
+  signal: AbortSignal
+}
+
+const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:'
+
 /** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */
 function reasoningEffort(effort: NonNullable<GenerateOptions['reasoningEffort']>): 'off' | 'low' | 'high' | 'max' {
   if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') {
@@ -67,6 +86,76 @@ function assertTextOnly(blocks: readonly ContentBlock[]): void {
   }
 }
 
+/** Reject roles whose DeepSeek history format cannot carry image input. */
+function assertSupportedImageRoles(messages: readonly Message[]): void {
+  for (const message of messages) {
+    if (message.role !== 'user' && contentHasImage(message.content)) {
+      throw new LlmError(
+        `The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`,
+        'UNSUPPORTED_CONTENT',
+      )
+    }
+  }
+}
+
+/** Resolve one durable image into its transient DeepSeek data-URL part. */
+async function imagePart(
+  block: Extract<ContentBlock, { type: 'image' }>,
+  attachments: AttachmentStore,
+  signal: AbortSignal,
+): Promise<WireImageContentPart> {
+  try {
+    const stored = await attachments.readImage(block.attachment, signal)
+    return {
+      type: 'image_url',
+      image_url: {
+        url: `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`,
+      },
+    }
+  } catch (error: unknown) {
+    if (error instanceof AttachmentError) {
+      throw new LlmError(error.message, error.code, { cause: error })
+    }
+    throw error
+  }
+}
+
+/** Convert user or nested tool-result blocks into ordered wire parts. */
+async function contentParts(
+  blocks: readonly ContentBlock[],
+  attachments: AttachmentStore,
+  signal: AbortSignal,
+): Promise<WireUserContentPart[]> {
+  const parts: WireUserContentPart[] = []
+  for (const block of blocks) {
+    switch (block.type) {
+      case 'text':
+        if (block.text.length > 0) parts.push({ type: 'text', text: block.text })
+        break
+      case 'image':
+        parts.push(await imagePart(block, attachments, signal))
+        break
+      case 'tool-result':
+        parts.push(...await contentParts(block.content, attachments, signal))
+        break
+      default:
+        // Other merge-extensible blocks are not DeepSeek user-input vocabulary.
+        break
+    }
+  }
+  return parts
+}
+
+/** Keep text-only user messages on the compact string wire form. */
+function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] {
+  const text: string[] = []
+  for (const part of parts) {
+    if (part.type === 'image_url') return [...parts]
+    text.push(part.text)
+  }
+  return text.join('')
+}
+
 /** Serialize one assistant message (text + reasoning + tool calls). */
 function serializeAssistant(message: Message): WireMessage {
   const text = flattenText(message.content)
@@ -141,23 +230,77 @@ export function serializeMessages(messages: Message[]): WireMessage[] {
 }
 
 /**
- * Build the full wire request. Always streaming (`stream: true`, usage
- * reporting on); optional fields are omitted rather than sent as null, so
- * provider defaults apply.
- * @param options - the harness request (model, history, system, tools, sampling).
- * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
- * @returns the chat-completions request body.
+ * Serialize image-capable history after resolving durable attachments.
+ * Consecutive tool results keep string `tool` messages and share one following
+ * user message containing their images.
+ * @param messages - transient request history after request-size offloading.
+ * @param attachments - durable image resolver.
+ * @param signal - cancellation for attachment reads.
+ * @returns ordered DeepSeek wire messages.
  */
-export function serializeRequest(
-  options: GenerateOptions,
-  defaults: RequestDefaults = {},
-): WireRequest {
-  const messages: WireMessage[] = []
-  if (options.system !== undefined) {
-    messages.push({ role: 'system', content: options.system })
+export async function serializeMessagesWithImages(
+  messages: readonly Message[],
+  attachments: AttachmentStore,
+  signal: AbortSignal,
+): Promise<WireMessage[]> {
+  assertSupportedImageRoles(messages)
+  const wire: WireMessage[] = []
+  let pendingToolImages: WireImageContentPart[] = []
+  const flushToolImages = (): void => {
+    if (pendingToolImages.length === 0) return
+    wire.push({
+      role: 'user',
+      content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages],
+    })
+    pendingToolImages = []
   }
-  messages.push(...serializeMessages(options.messages))
 
+  for (const message of messages) {
+    if (message.role === 'system') {
+      flushToolImages()
+      wire.push({ role: 'system', content: flattenText(message.content) })
+      continue
+    }
+    if (message.role === 'assistant') {
+      flushToolImages()
+      wire.push(serializeAssistant(message))
+      continue
+    }
+
+    const regular = message.content.filter(block => block.type !== 'tool-result')
+    const toolResults = message.content.filter((block): block is Extract<ContentBlock, { type: 'tool-result' }> => (
+      block.type === 'tool-result'
+    ))
+    const content = userContent(await contentParts(regular, attachments, signal))
+    if (content.length > 0 || toolResults.length === 0) {
+      flushToolImages()
+      wire.push({
+        role: 'user',
+        content,
+      })
+    }
+    for (const result of toolResults) {
+      const parts = await contentParts(result.content, attachments, signal)
+      const images = parts.filter((part): part is WireImageContentPart => part.type === 'image_url')
+      const text = parts.filter(part => part.type === 'text').map(part => part.text).join('')
+      wire.push({
+        role: 'tool',
+        tool_call_id: result.toolCallId,
+        content: text || (images.length > 0 ? '(see attached image)' : '(no output)'),
+      })
+      pendingToolImages.push(...images)
+    }
+  }
+  flushToolImages()
+  return wire
+}
+
+/** Assemble request fields shared by text-only and image-capable conversion. */
+function requestWithMessages(
+  options: GenerateOptions,
+  messages: WireMessage[],
+  defaults: RequestDefaults,
+): WireRequest {
   const tools: WireTool[] | undefined = options.tools?.map(tool => ({
     type: 'function',
     function: {
@@ -166,10 +309,7 @@ export function serializeRequest(
       parameters: tool.parameters,
     },
   }))
-  // A short title budget must produce visible text; conversation and
-  // compaction calls continue to inherit the adapter's thinking defaults.
   const resolvedThinking = resolveThinking(options, defaults)
-
   return {
     model: options.model,
     messages,
@@ -185,3 +325,48 @@ export function serializeRequest(
     ...options.stop !== undefined ? { stop: options.stop } : {},
   }
 }
+
+/**
+ * Build the full wire request. Always streaming (`stream: true`, usage
+ * reporting on); optional fields are omitted rather than sent as null, so
+ * provider defaults apply.
+ * @param options - the harness request (model, history, system, tools, sampling).
+ * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
+ * @returns the chat-completions request body.
+ */
+export function serializeRequest(
+  options: GenerateOptions,
+  defaults: RequestDefaults = {},
+): WireRequest {
+  const messages: WireMessage[] = []
+  if (options.system !== undefined) {
+    messages.push({ role: 'system', content: options.system })
+  }
+  messages.push(...serializeMessages(options.messages))
+
+  return requestWithMessages(options, messages, defaults)
+}
+
+/**
+ * Build one image-capable request while keeping durable bytes out of session
+ * messages. Oversized oldest images become deterministic text before any
+ * attachment read.
+ * @param options - harness request containing image-capable user content.
+ * @param images - attachment resolver, request bound, and cancellation.
+ * @param defaults - adapter-level thinking defaults.
+ * @returns the fully materialized DeepSeek request body.
+ */
+export async function serializeRequestWithImages(
+  options: GenerateOptions,
+  images: ImageSerializationOptions,
+  defaults: RequestDefaults = {},
+): Promise<WireRequest> {
+  assertSupportedImageRoles(options.messages)
+  const requestMessages = offloadRequestImages(options.messages, images.maxRequestImageBytes)
+  const messages: WireMessage[] = []
+  if (options.system !== undefined) {
+    messages.push({ role: 'system', content: options.system })
+  }
+  messages.push(...await serializeMessagesWithImages(requestMessages, images.attachments, images.signal))
+  return requestWithMessages(options, messages, defaults)
+}

+ 17 - 2
packages/llm/llm-deepseek/src/types.ts

@@ -35,10 +35,25 @@ export interface WireSystemMessage {
   content: string
 }
 
-/** User-role message: a single string of user input. */
+/** Text part inside a multimodal user message. */
+export interface WireTextContentPart {
+  type: 'text'
+  text: string
+}
+
+/** Base64 data URL part inside a multimodal user message. */
+export interface WireImageContentPart {
+  type: 'image_url'
+  image_url: { url: string }
+}
+
+/** Ordered input part accepted by a multimodal user message. */
+export type WireUserContentPart = WireTextContentPart | WireImageContentPart
+
+/** User-role message: text-only string or ordered multimodal input. */
 export interface WireUserMessage {
   role: 'user'
-  content: string
+  content: string | WireUserContentPart[]
 }
 
 /** Tool-role message: the result of one tool call, keyed by its call id. */

+ 166 - 5
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -3,6 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
+import { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import LlmRuntime, { createUserMessage,
   CONTEXT_WINDOW_EXCEEDED_CODE,
@@ -47,15 +49,31 @@ async function harness(baseURL: string, config: object = {}) {
 }
 
 /** Direct adapter over the plugin's real resolve step, with a static key. */
-function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {}): DeepSeekAdapter {
+function adapterOf(
+  config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {},
+  attachments?: AttachmentStore,
+): DeepSeekAdapter {
   const { apiKey, ...rest } = config
   return new DeepSeekAdapter({
     options: () => resolveAdapterOptions(rest),
     resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
     resolveUserId: () => TEST_USER_ID,
+    resolveAttachments: () => attachments,
   })
 }
 
+async function drain(stream: AsyncIterable<unknown>): Promise<void> {
+  for await (const _chunk of stream) { /* drain */ }
+}
+
+const imageRef: ImageAttachmentRef = {
+  attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
+  mediaType: 'image/png',
+  bytes: 3,
+  width: 1,
+  height: 1,
+}
+
 describe('DeepSeekAdapter against a mock server', () => {
   it('streams a text generation end to end through the assembler', async () => {
     const server = await mockServer([{ kind: 'sse', events: textEvents }])
@@ -90,6 +108,96 @@ describe('DeepSeekAdapter against a mock server', () => {
     expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact')
   })
 
+  it('sends a durable image as a base64 data URL for the vision model', async () => {
+    const server = await mockServer([{ kind: 'sse', events: textEvents }])
+    const signalSeen: (AbortSignal | undefined)[] = []
+    const attachments = {
+      readImage: vi.fn((ref: ImageAttachmentRef, signal?: AbortSignal) => {
+        signalSeen.push(signal)
+        return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) })
+      }),
+    } as unknown as AttachmentStore
+    const adapter = adapterOf({
+      baseURL: server.url,
+      models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+    }, attachments)
+
+    await drain(adapter.stream({
+      provider: 'deepseek-official',
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [
+          { type: 'text', text: 'describe ' },
+          { type: 'image', attachment: imageRef },
+        ],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }))
+
+    expect(server.requests[0]).toMatchObject({
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [{
+        role: 'user',
+        content: [
+          { type: 'text', text: 'describe ' },
+          { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } },
+        ],
+      }],
+    })
+    expect(signalSeen[0]).toBeInstanceOf(AbortSignal)
+  })
+
+  it.each(['deepseek-v4-flash', 'unlisted-pass-through'])(
+    'rejects image input for text-only model %s before credentials, attachments, or fetch',
+    async (model) => {
+      const server = await mockServer([])
+      const resolveApiKey = vi.fn(() => Promise.resolve('k'))
+      const resolveAttachments = vi.fn(() => ({}) as AttachmentStore)
+      const adapter = new DeepSeekAdapter({
+        options: () => resolveAdapterOptions({ baseURL: server.url }),
+        resolveApiKey,
+        resolveUserId: () => TEST_USER_ID,
+        resolveAttachments,
+      })
+
+      await expect(drain(adapter.stream({
+        provider: 'deepseek-official',
+        model,
+        messages: [createUserMessage({
+          content: [{ type: 'image', attachment: imageRef }],
+          source: { kind: 'plugin', plugin: 'test' },
+        })],
+      }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+      expect(resolveApiKey).not.toHaveBeenCalled()
+      expect(resolveAttachments).not.toHaveBeenCalled()
+      expect(server.requests).toHaveLength(0)
+    },
+  )
+
+  it('rejects vision input without an attachment provider before credentials or fetch', async () => {
+    const server = await mockServer([])
+    const resolveApiKey = vi.fn(() => Promise.resolve('k'))
+    const adapter = new DeepSeekAdapter({
+      options: () => resolveAdapterOptions({
+        baseURL: server.url,
+        models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+      }),
+      resolveApiKey,
+      resolveUserId: () => TEST_USER_ID,
+    })
+
+    await expect(drain(adapter.stream({
+      provider: 'deepseek-official',
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [{ type: 'image', attachment: imageRef }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+    expect(resolveApiKey).not.toHaveBeenCalled()
+    expect(server.requests).toHaveLength(0)
+  })
+
   it('streams raw chunks through ctx.llm.stream', async () => {
     const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }])
     const ctx = await harness(server.url)
@@ -386,7 +494,7 @@ describe('DeepSeekAdapter against a mock server', () => {
       .toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
     expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' }))
       .toBe('INVALID_REQUEST')
-    expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413')
+    expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST')
   })
 
   it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => {
@@ -760,6 +868,21 @@ describe('plugin registration and config', () => {
     ])
   })
 
+  it('defaults an adapter-supplied catalog entry to text input', async () => {
+    const connection = resolveAdapterOptions({ models: [] })
+    const adapter = new DeepSeekAdapter({
+      options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }),
+      resolveApiKey: () => Promise.resolve('k'),
+      resolveUserId: () => TEST_USER_ID,
+    })
+    await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{
+      provider: 'deepseek-official',
+      id: 'adapter-model',
+      name: 'adapter-model',
+      inputModalities: ['text'],
+    }])
+  })
+
   it('advertises configured models without restricting arbitrary request ids', async () => {
     const ctx = new Context()
     await ctx.plugin(LlmRuntime)
@@ -772,12 +895,13 @@ describe('plugin registration and config', () => {
           name: 'Private Reasoner',
           description: 'Higher reasoning budget',
           contextWindow: 64_000,
+          inputModalities: ['text', 'image'],
         },
       ],
     })
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
       { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] },
-      { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] },
+      { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] },
     ])
     await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast'))
       .resolves.toMatchObject({ context: { contextWindow: 32_000 } })
@@ -785,6 +909,7 @@ describe('plugin registration and config', () => {
       .resolves.toMatchObject({
         name: 'Private Reasoner',
         description: 'Higher reasoning budget',
+        inputModalities: ['text', 'image'],
       })
     await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted'))
       .resolves.toMatchObject({
@@ -823,13 +948,21 @@ describe('plugin registration and config', () => {
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([])
   })
 
-  it.each([
+  const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [
     [[{ id: '' }], /ids must be non-empty/],
     [[{ id: 'm', name: '' }], /empty name/],
     [[{ id: 'm', contextWindow: 0 }], /contextWindow/],
     [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/],
+    [[{ id: 'm', inputModalities: [] }], /inputModalities/],
+    [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/],
+    [[{
+      id: 'm',
+      inputModalities: ['audio'] as unknown as NonNullable<LlmDeepSeek.DeepSeekCatalogModel['inputModalities']>,
+    }], /expected "text" \| "image"/],
     [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/],
-  ] as const)('rejects invalid advisory model config', async (models, message) => {
+  ]
+
+  it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => {
     const ctx = new Context()
     await ctx.plugin(LlmRuntime)
     await expect(ctx.plugin(LlmDeepSeek, {
@@ -839,6 +972,18 @@ describe('plugin registration and config', () => {
     expect(ctx.llm.listProviders()).toEqual([])
   })
 
+  const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [
+    [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/],
+    [[{
+      id: 'm',
+      inputModalities: ['audio'] as unknown as NonNullable<LlmDeepSeek.DeepSeekCatalogModel['inputModalities']>,
+    }], /inputModalities must contain only "text" and "image"/],
+  ]
+
+  it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => {
+    expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message)
+  })
+
   it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => {
     expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] }))
       .toThrow(/maxTokens must be a positive integer/)
@@ -903,6 +1048,22 @@ describe('plugin registration and config', () => {
     },
   )
 
+  it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])(
+    'rejects invalid request image bound %s',
+    async (maxRequestImageBytes) => {
+      expect(() => resolveAdapterOptions({ maxRequestImageBytes }))
+        .toThrow(/maxRequestImageBytes must be a positive safe integer/)
+
+      const ctx = new Context()
+      await ctx.plugin(LlmRuntime)
+      await expect(ctx.plugin(LlmDeepSeek, {
+        baseURL: 'http://127.0.0.1:1',
+        maxRequestImageBytes,
+      })).rejects.toThrow(/maxRequestImageBytes/)
+      expect(ctx.llm.listProviders()).toEqual([])
+    },
+  )
+
   it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
     vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
     vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')

+ 73 - 3
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts

@@ -3,7 +3,14 @@ import { Context } from '@deepseek-ai/cordis'
 import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import LlmRuntime, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
+import LlmRuntime, { createUserMessage, INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
+import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import type {
+  ImageAttachmentLimits,
+  ImageAttachmentRef,
+  SaveImageAttachment,
+  StoredImageAttachment,
+} from '@deepseek-ai/dsh-attachment'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -14,6 +21,36 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
 
 const NS = settingsNamespace('llm-deepseek')
 const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
+const IMAGE_REF: ImageAttachmentRef = {
+  attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
+  mediaType: 'image/png',
+  bytes: 3,
+  width: 1,
+  height: 1,
+}
+
+class StaticAttachmentStore extends AttachmentStore {
+  readonly imageLimits: ImageAttachmentLimits = {
+    maxImageBytes: 16,
+    maxImagesPerMessage: 4,
+    maxMessageImageBytes: 64,
+    maxImagePixels: 4,
+    maxImageDimension: 4,
+    mediaTypes: ['image/png'],
+  }
+
+  validateImage(_input: SaveImageAttachment): Promise<void> {
+    return Promise.resolve()
+  }
+
+  saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
+    return Promise.resolve(IMAGE_REF)
+  }
+
+  readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise<StoredImageAttachment> {
+    return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) })
+  }
+}
 
 const cleanups: Array<() => Promise<void>> = []
 
@@ -47,6 +84,7 @@ async function boot(dir: string, config: object): Promise<Harness> {
     await ctx.fiber.dispose()
   })
   await ctx.plugin(LlmRuntime)
+  await ctx.plugin(StaticAttachmentStore)
   const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
   await settingsFiber
   await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
@@ -118,12 +156,44 @@ describe('request-level dynamic configuration', () => {
     const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
 
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
-    await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
+    await ctx.settings.update(NS, {
+      models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }],
+    })
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
-      { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text'] },
+      { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] },
     ])
   })
 
+  it('applies a changed request image bound to the next request', async () => {
+    vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
+    const dir = await home()
+    const server = await mockServer([
+      { kind: 'sse', events: textEvents },
+      { kind: 'sse', events: textEvents },
+    ])
+    const { ctx } = await boot(dir, {
+      baseURL: server.url,
+      models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+    })
+    const messages = [createUserMessage({
+      content: [
+        { type: 'image', attachment: IMAGE_REF },
+        { type: 'image', attachment: IMAGE_REF },
+      ],
+      source: { kind: 'plugin', plugin: 'test' },
+    })]
+
+    await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages })
+    await ctx.settings.update(NS, { maxRequestImageBytes: 4 })
+    await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages })
+
+    const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content
+    const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content
+    expect(JSON.stringify(first).match(/"type":"image_url"/g)).toHaveLength(2)
+    expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit')
+    expect(JSON.stringify(second).match(/"type":"image_url"/g)).toHaveLength(1)
+  })
+
   it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
     const dir = await home()
     const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })

+ 310 - 3
packages/llm/llm-deepseek/tests/serialize.spec.ts

@@ -1,13 +1,38 @@
-import { describe, expect, it } from 'vitest'
-import { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import { describe, expect, it, vi } from 'vitest'
+import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
 import { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
-import { serializeMessages, serializeRequest } from '../src/serialize.ts'
+import {
+  serializeMessages,
+  serializeMessagesWithImages,
+  serializeRequest,
+  serializeRequestWithImages,
+} from '../src/serialize.ts'
 
 function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
   return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides }
 }
 
+function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef {
+  return {
+    attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
+    mediaType,
+    bytes,
+    width: 1,
+    height: 1,
+  }
+}
+
+function attachmentStore(
+  readImage = vi.fn((ref: ImageAttachmentRef, _signal?: AbortSignal) => Promise.resolve({
+    ref,
+    data: Uint8Array.of(1, 2, 3),
+  })),
+): AttachmentStore {
+  return { readImage } as unknown as AttachmentStore
+}
+
 describe('serializeMessages', () => {
   it('maps user text to string content', () => {
     const wire = serializeMessages([
@@ -270,6 +295,288 @@ describe('serializeRequest', () => {
   })
 })
 
+describe('image serialization', () => {
+  it.each([
+    'image/png',
+    'image/jpeg',
+    'image/webp',
+    'image/gif',
+  ] as const)('preserves ordered text and %s image parts', async (mediaType) => {
+    const signal = new AbortController().signal
+    const readImage = vi.fn((ref: ImageAttachmentRef, received?: AbortSignal) => {
+      expect(received).toBe(signal)
+      return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) })
+    })
+    const wire = await serializeRequestWithImages(request({
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [
+          { type: 'text', text: 'before' },
+          { type: 'image', attachment: imageRef(mediaType) },
+          { type: 'text', text: 'after' },
+        ],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(readImage),
+      maxRequestImageBytes: 20 * 1024 * 1024,
+      signal,
+    })
+
+    expect(wire.messages).toEqual([{
+      role: 'user',
+      content: [
+        { type: 'text', text: 'before' },
+        { type: 'image_url', image_url: { url: `data:${mediaType};base64,AQID` } },
+        { type: 'text', text: 'after' },
+      ],
+    }])
+  })
+
+  it('serializes image-only user content without synthetic text', async () => {
+    const wire = await serializeRequestWithImages(request({
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [{ type: 'image', attachment: imageRef() }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(),
+      maxRequestImageBytes: 20 * 1024 * 1024,
+      signal: new AbortController().signal,
+    })
+
+    expect(wire.messages).toEqual([{
+      role: 'user',
+      content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }],
+    }])
+  })
+
+  it('keeps tool content textual and groups consecutive tool-result images afterward', async () => {
+    const messages = [
+      createUserMessage({
+        content: [{
+          type: 'tool-result',
+          toolCallId: CallId('first'),
+          content: [{ type: 'image', attachment: imageRef() }],
+        }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }),
+      createUserMessage({
+        content: [{
+          type: 'tool-result',
+          toolCallId: CallId('second'),
+          content: [
+            { type: 'text', text: 'caption' },
+            { type: 'image', attachment: imageRef('image/jpeg') },
+          ],
+        }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }),
+    ]
+
+    await expect(serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )).resolves.toEqual([
+      { role: 'tool', tool_call_id: 'first', content: '(see attached image)' },
+      { role: 'tool', tool_call_id: 'second', content: 'caption' },
+      {
+        role: 'user',
+        content: [
+          { type: 'text', text: 'Attached image(s) from tool result:' },
+          { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } },
+          { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } },
+        ],
+      },
+    ])
+  })
+
+  it('does not emit an empty user message for ignored content beside a tool result', async () => {
+    const messages = [createUserMessage({
+      content: [
+        { type: 'text', text: '' },
+        { type: 'chart', data: 'ignored' } as unknown as ContentBlock,
+        {
+          type: 'tool-result',
+          toolCallId: CallId('result'),
+          content: [{ type: 'text', text: 'ok' }],
+        },
+      ],
+      source: { kind: 'plugin', plugin: 'test' },
+    })]
+
+    await expect(serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )).resolves.toEqual([
+      { role: 'tool', tool_call_id: 'result', content: 'ok' },
+    ])
+  })
+
+  it('recursively converts nested tool-result content and preserves the empty fallback', async () => {
+    const messages = [createUserMessage({
+      content: [
+        {
+          type: 'tool-result',
+          toolCallId: CallId('nested'),
+          content: [{
+            type: 'tool-result',
+            toolCallId: CallId('inner'),
+            content: [{ type: 'text', text: 'inside' }],
+          }],
+        },
+        { type: 'tool-result', toolCallId: CallId('empty'), content: [] },
+      ],
+      source: { kind: 'plugin', plugin: 'test' },
+    })]
+
+    await expect(serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )).resolves.toEqual([
+      { role: 'tool', tool_call_id: 'nested', content: 'inside' },
+      { role: 'tool', tool_call_id: 'empty', content: '(no output)' },
+    ])
+  })
+
+  it('flushes tool-result images before system and assistant history', async () => {
+    const imageResult = (id: string) => createUserMessage({
+      content: [{
+        type: 'tool-result',
+        toolCallId: CallId(id),
+        content: [{ type: 'image', attachment: imageRef() }],
+      }],
+      source: { kind: 'plugin' as const, plugin: 'test' },
+    })
+    const messages = [
+      imageResult('before-system'),
+      createMessage({
+        role: 'system',
+        content: [{ type: 'text', text: 'system history' }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }),
+      imageResult('before-assistant'),
+      createMessage({
+        role: 'assistant',
+        content: [{ type: 'text', text: 'assistant history' }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }),
+    ]
+
+    const wire = await serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )
+    expect(wire).toEqual([
+      { role: 'tool', tool_call_id: 'before-system', content: '(see attached image)' },
+      expect.objectContaining({ role: 'user' }),
+      { role: 'system', content: 'system history' },
+      { role: 'tool', tool_call_id: 'before-assistant', content: '(see attached image)' },
+      expect.objectContaining({ role: 'user' }),
+      { role: 'assistant', content: 'assistant history' },
+    ])
+  })
+
+  it('offloads oldest images before reads and keeps the newest image', async () => {
+    const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({
+      ref,
+      data: Uint8Array.of(1, 2, 3),
+    }))
+    const wire = await serializeRequestWithImages(request({
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [
+          { type: 'image', attachment: imageRef('image/png', 3) },
+          { type: 'image', attachment: imageRef('image/jpeg', 3) },
+        ],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(readImage),
+      maxRequestImageBytes: 4,
+      signal: new AbortController().signal,
+    })
+
+    expect(wire.messages[0]).toMatchObject({
+      role: 'user',
+      content: [
+        { type: 'text', text: expect.stringContaining('older images are omitted first') as string },
+        { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } },
+      ],
+    })
+    expect(readImage).toHaveBeenCalledTimes(1)
+    expect(readImage.mock.calls[0]?.[0]).toMatchObject({ mediaType: 'image/jpeg' })
+  })
+
+  it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => {
+    const readImage = vi.fn()
+    await expect(serializeMessagesWithImages([createMessage({
+      role,
+      content: [{ type: 'image', attachment: imageRef() }],
+      source: { kind: 'plugin', plugin: 'test' },
+    })], attachmentStore(readImage), new AbortController().signal))
+      .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+    expect(readImage).not.toHaveBeenCalled()
+  })
+
+  it('rejects unsupported image history before request offloading can replace it', async () => {
+    const readImage = vi.fn()
+    await expect(serializeRequestWithImages(request({
+      messages: [createMessage({
+        role: 'system',
+        content: [{ type: 'image', attachment: imageRef('image/png', 300) }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(readImage),
+      maxRequestImageBytes: 1,
+      signal: new AbortController().signal,
+    })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+    expect(readImage).not.toHaveBeenCalled()
+  })
+
+  it('prepends the request system prompt on the image path', async () => {
+    const wire = await serializeRequestWithImages(request({
+      system: 'system prompt',
+      messages: [createUserMessage({
+        content: [{ type: 'image', attachment: imageRef() }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(),
+      maxRequestImageBytes: 20 * 1024 * 1024,
+      signal: new AbortController().signal,
+    })
+    expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' })
+  })
+
+  it('preserves stable attachment failure codes', async () => {
+    const readImage = vi.fn(() => Promise.reject(new AttachmentError(
+      'Stored attachment bytes are corrupt.',
+      'ATTACHMENT_CORRUPT',
+    )))
+    await expect(serializeMessagesWithImages([createUserMessage({
+      content: [{ type: 'image', attachment: imageRef() }],
+      source: { kind: 'plugin', plugin: 'test' },
+    })], attachmentStore(readImage), new AbortController().signal))
+      .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
+  })
+
+  it('preserves non-attachment resolver failures', async () => {
+    const failure = new Error('resolver failed')
+    const readImage = vi.fn(() => Promise.reject(failure))
+    await expect(serializeMessagesWithImages([createUserMessage({
+      content: [{ type: 'image', attachment: imageRef() }],
+      source: { kind: 'plugin', plugin: 'test' },
+    })], attachmentStore(readImage), new AbortController().signal)).rejects.toBe(failure)
+  })
+})
+
 describe('review fixes: assistant content shapes', () => {
   it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => {
     // Aborted/empty assistant turns: no text, no calls → "". The earlier

+ 21 - 77
packages/llm/llm-pi-ai/src/context.ts

@@ -4,7 +4,7 @@
  * @module dsh-llm-pi-ai/context
  */
 
-import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
+import { CallId, contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
 import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
 import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
@@ -26,82 +26,29 @@ function toolResultText(blocks: readonly ContentBlock[]): string {
     : block.type === 'tool-result' ? toolResultText(block.content) : '').join('')
 }
 
-/** Model-facing stand-in for an image dropped to fit the request bound. */
-export const OFFLOADED_IMAGE_TEXT
-  = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]'
-
-/** Base64 length of `bytes` raw bytes (4 output characters per 3 input bytes, padded). */
-function base64Length(bytes: number): number {
-  return Math.ceil(bytes / 3) * 4
-}
-
-/**
- * Select the images a request must drop to fit the per-request payload bound.
- * History order is oldest-first, so the most recent images are omitted last.
- * A single image larger than the bound is itself omitted. Locations use
- * message and nested block indexes so JSON replay cannot change the result by
- * splitting or preserving shared object identities.
- * @param messages - complete request history, oldest first.
- * @param maxRequestImageBytes - bound on total base64-encoded image payload; undefined leaves every image in place.
- * @returns the image locations the conversion replaces with {@link OFFLOADED_IMAGE_TEXT}.
- */
-function offloadedImages(
-  messages: readonly Message[],
-  maxRequestImageBytes: number | undefined,
-): ReadonlySet<string> {
-  const offloaded = new Set<string>()
-  if (maxRequestImageBytes === undefined) return offloaded
-  const images: { location: string; base64Bytes: number }[] = []
-  const collect = (messageIndex: number, blocks: readonly ContentBlock[], prefix: readonly number[] = []): void => {
-    for (const [blockIndex, block] of blocks.entries()) {
-      const path = [...prefix, blockIndex]
-      if (block.type === 'image') {
-        images.push({
-          location: `${messageIndex}:${path.join('.')}`,
-          base64Bytes: base64Length(block.attachment.bytes),
-        })
-      } else if (block.type === 'tool-result') {
-        collect(messageIndex, block.content, path)
-      }
+/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */
+function assertSupportedImageRoles(messages: readonly Message[]): void {
+  for (const message of messages) {
+    if (message.role !== 'user' && contentHasImage(message.content)) {
+      throw new LlmError(
+        `pi-ai cannot represent an image in an in-history ${message.role} message`,
+        'UNSUPPORTED_CONTENT',
+      )
     }
   }
-  for (const [messageIndex, message] of messages.entries()) collect(messageIndex, message.content)
-  let total = images.reduce((sum, image) => sum + image.base64Bytes, 0)
-  for (const image of images) {
-    if (total <= maxRequestImageBytes) break
-    offloaded.add(image.location)
-    total -= image.base64Bytes
-  }
-  return offloaded
-}
-
-interface LocatedContentBlock {
-  readonly block: ContentBlock
-  readonly path: readonly number[]
-}
-
-/** Attach stable nested indexes to blocks from one message. */
-function locatedBlocks(blocks: readonly ContentBlock[], prefix: readonly number[] = []): LocatedContentBlock[] {
-  return blocks.map((block, index) => ({ block, path: [...prefix, index] }))
 }
 
 async function userContent(
-  blocks: readonly LocatedContentBlock[],
+  blocks: readonly ContentBlock[],
   attachments: AttachmentStore,
-  offloaded: ReadonlySet<string>,
-  messageIndex: number,
 ): Promise<string | (TextContent | ImageContent)[]> {
   const content: (TextContent | ImageContent)[] = []
-  for (const { block, path } of blocks) {
+  for (const block of blocks) {
     switch (block.type) {
       case 'text':
         if (block.text.length > 0) content.push({ type: 'text', text: block.text })
         break
       case 'image': {
-        if (offloaded.has(`${messageIndex}:${path.join('.')}`)) {
-          content.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT })
-          break
-        }
         const stored = await attachments.readImage(block.attachment)
         content.push({
           type: 'image',
@@ -112,7 +59,7 @@ async function userContent(
       }
       case 'tool-result':
         {
-          const nested = await userContent(locatedBlocks(block.content, path), attachments, offloaded, messageIndex)
+          const nested = await userContent(block.content, attachments)
           if (typeof nested === 'string') {
             if (nested.length > 0) content.push({ type: 'text', text: nested })
           } else {
@@ -234,15 +181,13 @@ async function toPiContextWithImages(
   onReplayDegrade?: (reason: string) => void,
   maxRequestImageBytes?: number,
 ): Promise<PiContext> {
-  const offloaded = offloadedImages(options.messages, maxRequestImageBytes)
+  assertSupportedImageRoles(options.messages)
+  const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes)
   const toolNames = new Map<CallId, string>()
   const messages: PiMessage[] = []
 
-  for (const [messageIndex, message] of options.messages.entries()) {
+  for (const message of requestMessages) {
     if (message.role === 'system') {
-      if (contentHasImage(message.content)) {
-        throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT')
-      }
       // pi-ai has a single systemPrompt slot; in-history system messages are
       // folded into user messages to preserve order (rare in practice — the
       // harness sends the system prompt via options.system).
@@ -258,17 +203,16 @@ async function toPiContextWithImages(
       continue
     }
     // user role: text + tool results (each result becomes its own message).
-    const located = locatedBlocks(message.content)
-    const regular = located.filter(({ block }) => block.type !== 'tool-result')
-    const content = await userContent(regular, attachments, offloaded, messageIndex)
-    const results = located.filter((entry): entry is LocatedContentBlock & { block: Extract<ContentBlock, { type: 'tool-result' }> } => (
-      entry.block.type === 'tool-result'
+    const regular = message.content.filter(block => block.type !== 'tool-result')
+    const content = await userContent(regular, attachments)
+    const results = message.content.filter((block): block is Extract<ContentBlock, { type: 'tool-result' }> => (
+      block.type === 'tool-result'
     ))
     if (content.length > 0 || results.length === 0) {
       messages.push({ role: 'user', content, timestamp: 0 })
     }
-    for (const { block: result, path } of results) {
-      const resultContent = await userContent(locatedBlocks(result.content, path), attachments, offloaded, messageIndex)
+    for (const result of results) {
+      const resultContent = await userContent(result.content, attachments)
       messages.push({
         role: 'toolResult',
         toolCallId: result.toolCallId,

+ 63 - 5
packages/llm/llm-pi-ai/tests/context.spec.ts

@@ -1,9 +1,9 @@
 import { describe, expect, it, vi } from 'vitest'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import { CallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
-import { OFFLOADED_IMAGE_TEXT, toPiContext } from '../src/context.ts'
+import { toPiContext } from '../src/context.ts'
 import { toPiAssistant } from '../src/replay.ts'
 
 const ref: ImageAttachmentRef = {
@@ -140,6 +140,59 @@ describe('pi-ai request context conversion', () => {
     ])
   })
 
+  it('recursively converts nested tool-result text and images', async () => {
+    const callId = CallId('nested-call')
+    const context = await toPiContext(request([user([{
+      type: 'tool-result',
+      toolCallId: callId,
+      content: [
+        {
+          type: 'tool-result',
+          toolCallId: callId,
+          content: [{ type: 'text', text: 'nested text' }],
+        },
+        {
+          type: 'tool-result',
+          toolCallId: callId,
+          content: [{ type: 'image', attachment: ref }],
+        },
+      ],
+    }])]), attachments)
+
+    expect(context.messages).toEqual([{
+      role: 'toolResult',
+      toolCallId: 'nested-call',
+      toolName: 'unknown',
+      content: [
+        { type: 'text', text: 'nested text' },
+        { type: 'image', data: 'AQ==', mimeType: 'image/png' },
+      ],
+      isError: false,
+      timestamp: 0,
+    }])
+  })
+
+  it('flattens nested text-only tool results and ignores other block types without storage', () => {
+    const callId = CallId('nested-text')
+    expect(toPiContext(request([user([{
+      type: 'tool-result',
+      toolCallId: callId,
+      content: [
+        { type: 'chart', data: 'ignored' } as unknown as ContentBlock,
+        {
+          type: 'tool-result',
+          toolCallId: callId,
+          content: [{ type: 'text', text: 'nested' }],
+        },
+      ],
+    }])]))).toMatchObject({
+      messages: [{
+        role: 'toolResult',
+        content: [{ type: 'text', text: 'nested' }],
+      }],
+    })
+  })
+
   it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => {
     const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) }))
     const store = { readImage } as unknown as AttachmentStore
@@ -249,9 +302,14 @@ describe('pi-ai request context conversion', () => {
   })
 
   it('handles in-history system and assistant messages explicitly on the image path', async () => {
-    await expect(toPiContext(request([
-      history('system', [{ type: 'image', attachment: ref }]),
-    ]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+    for (const role of ['system', 'assistant'] as const) {
+      const readImage = vi.fn()
+      const store = { readImage } as unknown as AttachmentStore
+      await expect(toPiContext(request([
+        history(role, [{ type: 'image', attachment: ref }]),
+      ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+      expect(readImage).not.toHaveBeenCalled()
+    }
 
     await expect(toPiContext(request([
       history('system', [{ type: 'text', text: 'history system' }]),

+ 78 - 0
packages/llm/llm/src/content.ts

@@ -1,6 +1,11 @@
 /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
 
 import type { ContentBlock } from './types.ts'
+import type { Message } from './message.ts'
+
+/** Model-facing stand-in for an image removed to fit a provider request bound. */
+export const OFFLOADED_IMAGE_TEXT
+  = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]'
 
 /**
  * True when typed model content contains an image block, walking nested
@@ -14,3 +19,76 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean {
   return content.some(block => block.type === 'image'
     || (block.type === 'tool-result' && contentHasImage(block.content)))
 }
+
+/** Base64 length of raw image bytes, including padding. */
+function base64Length(bytes: number): number {
+  return Math.ceil(bytes / 3) * 4
+}
+
+/** Collect base64 payload lengths in request and nested-block order. */
+function collectImageLengths(blocks: readonly ContentBlock[], lengths: number[]): void {
+  for (const block of blocks) {
+    if (block.type === 'image') {
+      lengths.push(base64Length(block.attachment.bytes))
+    } else if (block.type === 'tool-result') {
+      collectImageLengths(block.content, lengths)
+    }
+  }
+}
+
+/** Replace the first `remaining.count` image occurrences without mutating durable messages. */
+function replaceOldestImages(
+  blocks: readonly ContentBlock[],
+  remaining: { count: number },
+): ContentBlock[] {
+  let next: ContentBlock[] | undefined
+  for (const [index, block] of blocks.entries()) {
+    if (block.type === 'image' && remaining.count > 0) {
+      remaining.count -= 1
+      next ??= blocks.slice(0, index)
+      next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT })
+      continue
+    }
+    if (block.type === 'tool-result') {
+      const content = replaceOldestImages(block.content, remaining)
+      if (content !== block.content) {
+        next ??= blocks.slice(0, index)
+        next.push({ ...block, content })
+        continue
+      }
+    }
+    next?.push(block)
+  }
+  return next ?? blocks as ContentBlock[]
+}
+
+/**
+ * Return transient request messages whose oldest images are replaced until
+ * their accumulated base64 payload fits the configured bound. The selection
+ * is deterministic from durable message order and attachment metadata; a
+ * provider can serialize the returned messages without reading omitted bytes.
+ * @param messages - complete request history, oldest first.
+ * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
+ * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
+ */
+export function offloadRequestImages(
+  messages: readonly Message[],
+  maxRequestImageBytes: number | undefined,
+): readonly Message[] {
+  if (maxRequestImageBytes === undefined) return messages
+  const lengths: number[] = []
+  for (const message of messages) collectImageLengths(message.content, lengths)
+  let total = lengths.reduce((sum, bytes) => sum + bytes, 0)
+  let count = 0
+  for (const bytes of lengths) {
+    if (total <= maxRequestImageBytes) break
+    total -= bytes
+    count += 1
+  }
+  if (count === 0) return messages
+  const remaining = { count }
+  return messages.map((message) => {
+    const content = replaceOldestImages(message.content, remaining)
+    return content === message.content ? message : { ...message, content }
+  })
+}

+ 89 - 0
packages/llm/llm/tests/content.spec.ts

@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages } from '../src/index.ts'
+import type { ContentBlock } from '../src/index.ts'
+
+const source = { kind: 'plugin' as const, plugin: 'test' }
+
+function image(bytes: number): ContentBlock {
+  return {
+    type: 'image',
+    attachment: {
+      attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
+      mediaType: 'image/png',
+      bytes,
+      width: 1,
+      height: 1,
+    },
+  }
+}
+
+describe('offloadRequestImages', () => {
+  it('preserves the original request when its base64 payload fits exactly', () => {
+    const messages = [createUserMessage({ content: [image(3), image(3)], source })]
+    expect(offloadRequestImages(messages, 8)).toBe(messages)
+  })
+
+  it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => {
+    const rawImageBytes = 3 * 1024 * 1024
+    const maxRequestImageBytes = 20 * 1024 * 1024
+    const exact = [createUserMessage({
+      content: Array.from({ length: 5 }, () => image(rawImageBytes)),
+      source,
+    })]
+    expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact)
+
+    const over = [createUserMessage({
+      content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))],
+      source,
+    })]
+    expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([
+      { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+      ...Array.from({ length: 4 }, () => image(rawImageBytes)),
+    ])
+  })
+
+  it('replaces the oldest nested occurrences without mutating durable messages', () => {
+    const shared = image(3)
+    const messages = [
+      createUserMessage({
+        content: [{
+          type: 'tool-result',
+          toolCallId: CallId('shot'),
+          content: [shared],
+        }],
+        source,
+      }),
+      createUserMessage({ content: [shared, image(3)], source }),
+    ]
+
+    const fitted = offloadRequestImages(messages, 8)
+    expect(fitted).not.toBe(messages)
+    expect(fitted[0]?.content).toEqual([{
+      type: 'tool-result',
+      toolCallId: CallId('shot'),
+      content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }],
+    }])
+    expect(fitted[1]?.content).toEqual([shared, image(3)])
+    expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] })
+  })
+
+  it('replaces a single image that cannot fit', () => {
+    const messages = [createUserMessage({ content: [image(300)], source })]
+    expect(offloadRequestImages(messages, 8)[0]?.content)
+      .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }])
+  })
+
+  it('keeps unchanged nested content while replacing a later image', () => {
+    const nested = {
+      type: 'tool-result' as const,
+      toolCallId: CallId('text-only'),
+      content: [{ type: 'text' as const, text: 'kept' }],
+    }
+    const messages = [createUserMessage({ content: [nested, image(3)], source })]
+    expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([
+      nested,
+      { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+    ])
+  })
+})

+ 3 - 0
pnpm-lock.yaml

@@ -5325,6 +5325,9 @@ importers:
       '@deepseek-ai/dsh-anonymous-user-id':
         specifier: workspace:^
         version: link:../../identity/anonymous-user-id
+      '@deepseek-ai/dsh-attachment':
+        specifier: workspace:^
+        version: link:../../attachment/attachment
       '@deepseek-ai/dsh-credentials':
         specifier: workspace:^
         version: link:../../credentials/credentials