diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index fa0e4caa36..c3ae3421d5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -327,20 +327,24 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co
export interface Config {
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
dshHome?: string
- /** Maximum encoded bytes accepted for one image. */
+ /** Maximum encoded bytes accepted for one submitted image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
- /** Maximum intrinsic width multiplied by height accepted for one image. */
+ /** Maximum intrinsic width multiplied by height accepted for one submitted image. */
maxImagePixels?: number
- /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */
+ /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */
maxImageDimension?: number
+ /** Long-edge pixel target of the stored canonical encoding. */
+ canonicalMaxDimension?: number
+ /** Encoded-byte target of the stored canonical encoding. */
+ canonicalMaxBytes?: number
}
```
-Source: [`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:36`](../packages/attachment/attachment-local/src/index.ts)
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index c2d7e44403..a6f12fdad5 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -250,10 +250,10 @@ const SCENARIOS: Scenario[] = [
toolSchemasSource: 'read-image',
configPath: IMAGE_TEXT_ROUTE_CONFIG,
},
- // Authored keyless replay of the oversized-image refusal: admission rejects
- // the 2001x1 fixture at the default 2000px per-side limit, the model sees a
- // recoverable tool error, and the turn still completes — the image never
- // enters durable history.
+ // Authored keyless replay of wide-image admission: the 2001x1 fixture sits
+ // inside the wide source envelope and the canonical budget, so read_image
+ // succeeds and the attachment keeps the source bytes byte-identically —
+ // the same read the pre-canonicalization 2000px admission cap refused.
{
name: 'read-image-dimension',
hasModelTurn: true,
diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/input.json b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json
index 43e6299ef8..ff366b1109 100644
--- a/examples/acp-agent/tests/snapshots/read-image-dimension/input.json
+++ b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json
@@ -8,7 +8,7 @@
},
{
"op": "prompt",
- "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."
+ "text": "Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."
}
]
}
diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl
index db755998fb..9bd9a47fb2 100644
--- a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl
@@ -1,9 +1,9 @@
{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0}
-{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"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"}]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
-{"type":"user/message","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","data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
{"type":"user/message","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","data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -14,13 +14,13 @@
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","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","data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}
-{"type":"tool/result","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":"tool/result","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":"{{cwd}}/wide.png\nimage\n\nimage/png image, 2001x1 px, 133 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:0333f95051f5c038cab720d90112f1775e9ff1f8f7dddc86653e80ff241c5720","mediaType":"image/png","bytes":133,"width":2001,"height":1,"name":"wide.png"}}],"isError":false}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WIDE"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","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":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"WIDE"}],"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","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl
index 7dbc881712..80d27b8114 100644
--- a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl
@@ -1,4 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TOOLARGE"}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WIDE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml
index 1d7c63c469..11216d1aa1 100644
--- a/packages/attachment/attachment-local/README.i18n.yaml
+++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md
-README.md: e4f2d5748768a1dc2a6b79c3ed9e364c56a67248
-README.zh.md: 6b548fb993faef996f1508ba9f9efc31b20fea64
+README.md: e8f89906f7bedb20b80a04ab6d2aa4b80c6d746f
+README.zh.md: 61e1dde94751436bb4d8ff4dde1b68b1b10fc005
diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md
index e4f2d57487..e8f89906f7 100644
--- a/packages/attachment/attachment-local/README.md
+++ b/packages/attachment/attachment-local/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte, total-pixel, and per-side dimension limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. The per-side default (2000px) stays below the strictest dimension bound deployed model routes enforce on requests carrying many images: an admitted image rides every later request of its session, so admission is the last point where a provider-rejected image can be kept out of durable history.
+The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget is stored byte-identically, so equal originals keep deduplicating to one content address; GIF always re-encodes to the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
@@ -12,10 +12,11 @@ Indirectly, through durable replay of historical user images and structured mode
#### KV Cache effect
-None beyond the image block owned by the requesting adapter.
+Canonicalization happens once at admission and is deterministic, so a stored image contributes identical request bytes on every later turn; nothing here re-encodes per request.
## Known Limitations and Deferred Work
- Objects are retained indefinitely; reference-aware garbage collection is deferred.
- The local backend assumes the host and provider adapter share this filesystem service.
-- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned.
+- Animated GIF sources keep only their first frame; animation is outside the version-one image contract.
+- The canonical encoder is pinned by the installed sharp/libvips build; an encoder upgrade re-addresses future saves of the same source while already-stored objects stay valid.
diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md
index 6b548fb993..61e1dde947 100644
--- a/packages/attachment/attachment-local/README.zh.md
+++ b/packages/attachment/attachment-local/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节、总像素和单边尺寸限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。单边默认值(2000px)低于已部署模型路由对携带多张图片的请求所强制执行的最严格尺寸上限:一张已接纳的图片会随会话之后的每次请求发送,准入是把必然被上游拒绝的图片挡在持久历史之外的最后一道关口。
+这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图按字节原样存储,因此相同原图始终去重到同一个内容地址;GIF 一律重编码为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
@@ -12,10 +12,11 @@
#### KV 缓存影响
-除发起请求的适配器所持有的图片块外,不产生其他影响。
+规范化只在准入时发生一次且是确定性的,因此一张已存储的图片在之后每一轮贡献完全相同的请求字节;这里没有任何按请求重编码的环节。
## 已知限制与待完成工作
- 对象会无限期保留;基于引用的垃圾回收尚未实现。
- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。
-- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。
+- 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。
+- 规范编码器由安装的 sharp/libvips 构建钉定;编码器升级会让同一源图之后的保存得到新地址,已存储对象保持有效。
diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml
index fd02d455a4..97ec722871 100644
--- a/packages/attachment/attachment/README.i18n.yaml
+++ b/packages/attachment/attachment/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/attachment/attachment/README.md
-README.md: 19232bd4bb86ed33e56fcdca93999967822422ab
-README.zh.md: e5e7aab7c1af30b2b101bdcd218044cd1095ae0d
+README.md: 89bc3ca3a288450c43fefb5dde38da7f65218f43
+README.zh.md: ca13c9e66234b280f7fa9d01fc80bd026604831f
diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md
index 19232bd4bb..89bc3ca3a2 100644
--- a/packages/attachment/attachment/README.md
+++ b/packages/attachment/attachment/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
-Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
+Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md
index e5e7aab7c1..ca13c9e662 100644
--- a/packages/attachment/attachment/README.zh.md
+++ b/packages/attachment/attachment/README.zh.md
@@ -4,7 +4,7 @@
持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
-未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
+未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml
index 068531d79a..3d9c4606c4 100644
--- a/packages/fs/tool-fs/README.i18n.yaml
+++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md
-README.md: ce7c0ea9070e30c1e6b538933ff5c4605b8d59cc
-README.zh.md: 88d27289a7ef087aa8ad2791fc9b9e0d3e1fba2e
+README.md: 22384ddb18f2b36e9b8a177ee62eed9424ddcd6a
+README.zh.md: 74c41f4f25d19089c40a52cff4e3dfa654630b1a
diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md
index ce7c0ea907..22384ddb18 100644
--- a/packages/fs/tool-fs/README.md
+++ b/packages/fs/tool-fs/README.md
@@ -38,7 +38,7 @@ All keys are optional; the defaults are the shipped read caps.
Field names are snake_case to match Claude Code and existing harness tool schemas.
-Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
+Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }` (the source fields appear only when the attachment store's canonical encoding downscaled the file, and the envelope then names the coordinate multiplier back to the original), `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
## The tool is the executor; policy is an event gate
diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md
index 88d27289a7..74c41f4f25 100644
--- a/packages/fs/tool-fs/README.zh.md
+++ b/packages/fs/tool-fs/README.zh.md
@@ -38,7 +38,7 @@ await ctx.plugin(ToolFs) // this package — re
字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。
-规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
+规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`(source 两个字段仅在附件存储的规范编码缩小了该文件时出现,此时信封会写明换算回原图的坐标倍率),`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
## 工具就是执行器;策略是事件门禁