diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 6a379a3532..66ba6d1139 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: ada15d540539977c631e359ffdc7baa4fa84c78e -2026-08-20-unified-image-request-pipeline.zh.md: 85c9a1f837d82cba2bc62b30402433f50c873cbe +2026-08-20-unified-image-request-pipeline.md: 85296f1d8bd7d6ee458f8bc4230e52f1d3128bff +2026-08-20-unified-image-request-pipeline.zh.md: bcbc0110001e9f974e57a456140e9ecdf4eb888c diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index ada15d5405..85296f1d8b 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -22,15 +22,15 @@ Batch admission prepares and verifies every normalized attachment once before pu ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles -Every retained request image is preceded by its complete attachment id and actual request dimensions. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. +Every retained request image is preceded by its display name or complete attachment id and actual request dimensions. The attachment provider can supply its host object location; the LLM consumer combines it with the current filesystem mapping before adding an absolute read-only path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is resolved independently from the deterministic request version and does not enter its `variantId`, the durable reference, or the session log. ### DeepSeek Files lifecycle diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 85c9a1f837..bcbc011000 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -22,15 +22,15 @@ Status: implemented ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 ### 稳定句柄 -每张保留请求图片前都有完整附件 ID 和实际请求尺寸。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。 +每张保留请求图片前都有显示名称或完整附件 ID,以及实际请求尺寸。附件提供方可以给出宿主对象位置;LLM 消费方将其与当前文件系统映射组合后,再加入绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径独立于确定性请求版本解析,不进入其 `variantId`、持久引用或会话日志。 ### DeepSeek Files 生命周期 diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml similarity index 52% rename from .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml index 8bf3d97c55..b9516c493a 100644 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml @@ -1,6 +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/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md -2026-08-22-native-windows-blocks-pull-request-aggregate.md: ddec9536cbb350d3792ae547150b175ef21f1b9e -2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md: 94fa8b3836c15b9c977c39c7539cbb1be5fc882b +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md +2026-08-21-model-readable-image-paths.md: 728d25ee84836a07805f9d8728e47e9319e0863d +2026-08-21-model-readable-image-paths.zh.md: 2596dec5a21a8908d5875ab031bed6655b2305ca diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md new file mode 100644 index 0000000000..728d25ee84 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -0,0 +1,47 @@ +# Agent Note: Execution-world image paths in model requests + +Status: implemented + +English | [中文](2026-08-21-model-readable-image-paths.zh.md) + +## Problem + +An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable models receive its request preview, but the prior descriptor gives them no filesystem location for later image operations. Agents consequently search the workspace and temporary directories or ask the user where the file is. The request preview and stored normalized attachment may both differ from the upload, so their dimensions, format, and byte size cannot establish the upload's original properties. + +## Decision + +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageHostPath(ref)` exposes only the attachment provider's optional host object location. The local provider derives that absolute path from the resolved `DSH_HOME`, attachment storage version, and validated digest; it does not inspect the model-tool execution world. + +Each LLM provider plugin supplies the bridge at request assembly. It combines the attachment store with the current `ctx.fs.processPathFromHostPath(hostPath)` mapping and passes the resulting `ImageAttachmentAccess` independently into retained-image serialization and offload placeholders. The filesystem service is authoritative for this question because the mounted filesystem and subprocess providers share one execution world. A separate subprocess-presence check would not prove path reachability. Host-backed filesystems return a process path; E2B and other remote backends without a shared mount return no mapping. + +`RequestImageAttachment` remains a deterministic version selected by the attachment and route policy. It contains `variantId`, encoded bytes, dimensions, and encoding metadata, but no access path. Execution-world access may change with the host or mounted providers and does not participate in `variantId`. + +The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the execution-world path when the bridge resolves one. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. + +Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and access resolved for that request without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. + +Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Host-location resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. + +Absolute paths stay out of session events and request-image versions. Model-visible path text is reconstructed from the logged attachment reference, the current attachment provider, and the filesystem mounted for the current execution world. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. + +## Alternatives considered + +**Persist the absolute path in `ImageAttachmentRef`.** A durable host path becomes stale after moving a session, changing `DSH_HOME`, or mounting another provider. Resolving it at request time preserves portable history. + +**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns the object location; the shared LLM helper owns the composition with an execution-world mapper. + +**Let the attachment provider inspect `ctx.fs`.** The attachment provider knows where its object lives but does not own the model tools' execution environment. Combining both services in the LLM consumer also avoids a package reference cycle between attachment, filesystem, and LLM definitions. + +**Store `ImageAttachmentAccess` in `RequestImageAttachment`.** Request versions are deterministic cache and upload values. A path that changes with the host or execution environment must not affect their identity or appear to be part of their reproducible data. + +**Infer path sharing from the filesystem provider's package or class name.** Provider identity does not establish that a host file is mounted into its execution world. The filesystem provider instead answers the exact mapping question and can support an explicit shared mount without changing the attachment provider. + +**Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery. + +## Verification + +Package tests cover provider host-location defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, access passed independently from request versions, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. + +## Consequences + +When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths can change while deterministic image bytes, `variantId`, and session references remain unchanged; the changed historical text can prevent KV-cache reuse from the first affected image even when no image is offloaded. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md new file mode 100644 index 0000000000..2596dec5a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -0,0 +1,47 @@ +# Agent Note:在模型请求中提供执行环境图片路径 + +状态:已实现 + +[English](2026-08-21-model-readable-image-paths.md) | 中文 + +## 问题 + +上传图片会变成不透明的持久 `ImageAttachmentRef`。支持图片的模型可以收到请求预览,但原有描述没有给出供后续图片操作使用的文件系统位置。因此 agent 会搜索工作区和临时目录,或询问用户文件在哪里。请求预览和存储的规范化附件都可能与上传图片不同,所以它们的尺寸、格式和字节数不能证明上传图片原本的属性。 + +## 决策 + +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageHostPath(ref)` 只公开附件提供方可选的宿主对象位置。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生该绝对路径,不检查模型工具的执行环境。 + +每个 LLM 提供方插件在组装请求时提供桥接逻辑。它把附件存储与当前 `ctx.fs.processPathFromHostPath(hostPath)` 映射组合起来,再将得到的 `ImageAttachmentAccess` 单独传给保留图片序列化和 offload 占位内容。当前文件系统服务可以回答这个问题,因为挂载的文件系统和子进程提供方共享同一执行环境。另行检查子进程服务是否存在并不能证明路径可达。宿主文件系统返回进程路径;E2B 和其他没有共享挂载的远程后端不返回映射。 + +`RequestImageAttachment` 仍是由附件和路由策略确定的请求版本。它包含 `variantId`、编码字节、尺寸和编码元数据,不包含访问路径。执行环境访问方式可能随宿主或挂载的提供方变化,不参与 `variantId`。 + +共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及桥接逻辑能够解析出的执行环境路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 + +请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和本次请求解析出的访问方式,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 + +描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。宿主位置解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 + +绝对路径不会进入会话事件或请求图片版本。模型可见路径根据已记录附件引用、当前附件提供方和当前执行环境挂载的文件系统重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行环境中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 + +## 考虑过的替代方案 + +**把绝对路径持久保存到 `ImageAttachmentRef`。** 移动会话、更改 `DSH_HOME` 或挂载其他提供方后,持久宿主路径会失效。请求时解析可以保持历史可移植。 + +**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。附件提供方拥有对象位置,共用 LLM 帮助函数负责与执行环境映射组合。 + +**让附件提供方检查 `ctx.fs`。** 附件提供方知道对象在哪里,但不拥有模型工具的执行环境。在 LLM 消费方组合两个服务,也避免了附件、文件系统和 LLM 定义包之间的项目引用循环。 + +**把 `ImageAttachmentAccess` 放进 `RequestImageAttachment`。** 请求版本是确定性的缓存和上传值。随宿主或执行环境变化的路径不能影响其身份,也不能表现为可重复生成的数据。 + +**根据文件系统提供方的包名或类名判断是否共享路径。** 提供方身份不能证明宿主文件已挂载到其执行世界。文件系统提供方直接回答具体路径能否映射,也允许未来的显式共享挂载在不修改附件提供方的情况下返回映射。 + +**增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。 + +## 验证 + +包测试覆盖提供方宿主位置默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、独立于请求版本传入访问方式、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 + +## 后果 + +执行环境能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。路径变化时,确定性图片字节、`variantId` 和会话引用可以保持不变;即使没有图片被 offload,变化后的历史文本也可能使 KV 缓存从首张受影响图片起无法复用。没有共享挂载的远程执行环境会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml index dc243ba95c..78ff70c1c1 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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/process/2026-07-31-coverage-exempt-heavy-suites.md -2026-07-31-coverage-exempt-heavy-suites.md: 35642c41c0140b5a39be7da4668b33b70f858a85 -2026-07-31-coverage-exempt-heavy-suites.zh.md: cefade080581e4c20a71269bef638e12559153ae +2026-07-31-coverage-exempt-heavy-suites.md: 1f468a69321b451593a9279cfebc1b457fb08a47 +2026-07-31-coverage-exempt-heavy-suites.zh.md: 7e519f44c8321b6b99c04c6af56c4cfa5b641663 diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md index 35642c41c0..1f468a6932 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md @@ -10,8 +10,6 @@ The CI coverage lane (`check:ci:coverage`) had its wall clock pinned by a handfu The decisive waste: the instrumentation tax these suites paid contributed **nothing** to the per-file 100% thresholds — the measured code they execute in-process is either outside the threshold scope already or independently fully covered by other suites. Running them instrumented traded lane time for zero information. -The Web Worker transform corpus exposed the same waste on native Windows: `transform-corpus.spec.ts` spent 279 seconds inside one 442-second coverage partition while the other seven partitions settled in 110–161 seconds. Its real checker runs package source only in a spawned Node process, outside the parent Vitest worker's v8 coverage session, so the slow partition produced no threshold data from that work. - ## Decision The `ci-coverage` aggregate splits into two parallel gates; every test still runs, and only the heavy suites stop paying the instrumentation tax: @@ -19,12 +17,10 @@ The `ci-coverage` aggregate splits into two parallel gates; every test still run - **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged. - **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole. -Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. Linux overlaps four partition children, two exempt workers, and up to eight corpus children, so this combined fan-out is the first check if that lane regresses. Native Windows runs the exempt gate after the instrumented merge, while the lightweight observational inventory overlaps the exempt work, so the full-corpus child does not compete with sixteen coverage processes. The Oxlint contract suite atomically publishes scanner-valid temporary package probes and hides its script-only probes from glob discovery. +Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. `scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift. -`transform-corpus.spec.ts` discovers the complete built-bundle set once, assigns every path to exactly one of up to eight non-empty Node-loader children, and asserts the shard union before launch. `client-runtime` follows `acp-snapshot` for its pinned Vitest-state exemption, while `win32-process` follows `sandbox-windows-acl` for its pinned Koffi exemption. - ### The roster, reconciled entry by entry A suite contributes to coverage exactly when it executes measured files in-process (`coverage.include` spans the package src trees). The current roster, audited: @@ -34,7 +30,6 @@ A suite contributes to coverage exactly when it executes measured files in-proce | All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with | | tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) | | `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts`, `scripts/translation-pairing-merge.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | -| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | None — its package-source imports and the complete bundle sweep run in a spawned Node process | The Web Worker runtime's in-process unit suites carry its source coverage | ### Membership contract @@ -54,22 +49,15 @@ Coverage-result invariance therefore does not rest on humans maintaining the ros - **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it. - **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction. - **Cross-runner sharding (`--shard` + blob merge).** Rejected because a matrix, artifact pipeline, and merge job would add a second workflow topology. The selected [in-job partitioning](2026-08-18-in-job-partitioned-coverage.md) uses Vitest shards only as local single-worker processes inside the existing job. -- **Keep the transform corpus in one Node process.** Rejected because its serial loader becomes the Windows heavy gate's longest tail under host contention. Eight local children retain the same file set, per-file oracle, loader-sensitive affinities, and one blocking Vitest verdict. - **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal. ## Verification Measured on CI (16-core runner): the gate segment went from 424 seconds to the two gates in parallel — `test:coverage` 95.9 s + `test:coverage-exempt-heavy` 71.1 s — with the lane converging on the slower at about 96 seconds; the instrumented gate reported zero threshold errors both before and after the split. `vitest list` verifies the env toggle adds and removes exactly the exempt set; `run-gates.spec.ts` covers the aggregate graph construction. -The Web Worker corpus entry is pinned by a partitioned aggregate that runs all 15,250 tests and reports 100% for 45,959 statements, 28,116 branches, 9,781 functions, and 40,550 lines. A focused instrumented corpus run records no package source from its child process; the paired list check proves the spec is absent from the instrumented inventory and present in the uninstrumented inventory. - -The eight-child corpus run checks the same 239 native Windows bundles with 234 exact export matches, four pinned loader exemptions, one sentinel refusal, and no drift. The ARM64 VM measures 25.44 seconds for the sharded Vitest path versus 29.59 seconds for the unsharded checker; the complete x64 job remains the contended-host timing proof. - ## Consequences - The exempt suites execute without adding instrumentation cost to the thresholded gate; partitioned wall-clock measurements belong to the [in-job partitioning decision](2026-08-18-in-job-partitioned-coverage.md). -- Native Windows schedules the exempt suites after instrumented coverage and overlaps them with observational checks; Linux retains the parallel coverage split. -- The corpus suite uses up to eight non-empty child Node loaders but emits one blocking test result; its affinity roster is part of the exemption oracle and must move with affected bundles. - `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through. - Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently. - The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail. diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md index cefade0805..7e519f44c8 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md @@ -10,8 +10,6 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 关键的浪费在于:这些套件缴纳的插桩税对 per-file 100% 阈值**没有任何贡献**——它们进程内执行的被度量代码,要么本来就不在阈值口径内,要么已由其他套件独立满覆盖。继续在插桩下运行它们,纯粹是用 lane 时长换零信息。 -Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`transform-corpus.spec.ts` 在一个 442 秒的覆盖率分区中占用 279 秒,而其余七个分区在 110–161 秒内完成。它的真实检查器只在 spawn 的 Node 子进程中运行包源码,处于父 Vitest worker 的 v8 覆盖率会话之外,因此这个慢分区没有从该工作中产生任何阈值数据。 - ## Decision `ci-coverage` 聚合拆成两个并行 gate,全部测试仍然执行,只有重型套件不再交插桩税: @@ -19,12 +17,10 @@ Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`trans - **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。 - **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。 -Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。Linux 会让 4 个分区子进程、2 个豁免 worker 与最多 8 个语料库子进程重叠,因此该通道变慢时应先检查这组并发。原生 Windows 在插桩报告合并后运行豁免门禁,同时让轻量观测性清单与豁免工作重叠,因此完整语料库子进程不会与 16 个覆盖率进程争用资源。Oxlint 约定套件会原子发布满足源码扫描要求的包内临时探针,并把只属于脚本的探针对 glob 发现隐藏。 +Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。 `scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格约定与 filter/exclude 配对,防止两侧漂移。 -`transform-corpus.spec.ts` 只发现一次完整的已构建 bundle 集合,把每条路径恰好分配给最多 8 个非空 Node loader 子进程之一,并在启动前断言分片并集。`client-runtime` 会为固定的 Vitest 状态豁免跟在 `acp-snapshot` 之后,`win32-process` 则会为固定的 Koffi 豁免跟在 `sandbox-windows-acl` 之后。 - ### 豁免名单与逐项对账 一个套件对覆盖率有贡献,当且仅当它在进程内执行了被度量的文件(`coverage.include` = 包 src 树)。现行名单逐项核对: @@ -34,7 +30,6 @@ Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分 | typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 | | 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) | | `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts`、`scripts/translation-pairing-merge.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | -| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | 无——包源码 import 与完整 bundle 扫描都在 spawn 的 Node 子进程中运行 | Web Worker runtime 的进程内单元套件承担其源码覆盖率 | ### 成员资格约定 @@ -54,22 +49,15 @@ per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默 - **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。 - **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。 - **跨 runner 分片(`--shard` + blob 合并)。** 不予采用,因为 matrix、产物流水线和合并 job 会引入第二套工作流拓扑。所选的 [job 内分区](2026-08-18-in-job-partitioned-coverage.zh.md)只把 Vitest shard 用作既有 job 内的本地单 worker 进程。 -- **让转换语料库保留在一个 Node 进程中。** 不予采用,因为串行 loader 在宿主争用下成为 Windows 重型门禁的最长尾部。八个本地子进程保留相同文件集、逐文件判定器、对 loader 敏感的亲和顺序,以及一个阻断性 Vitest 判定。 - **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。 ## Verification CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate 并行 `test:coverage` 95.9 秒 + `test:coverage-exempt-heavy` 71.1 秒,lane 收敛于较慢者约 96 秒;拆分前后插桩 gate 阈值错误均为零。`vitest list` 验证 env 开关两态恰好增删豁免集;`run-gates.spec.ts` 覆盖聚合图构造。 -Web Worker 语料库条目由分区聚合固定:它执行全部 15,250 个测试,并对 45,959 条语句、28,116 个分支、9,781 个函数和 40,550 行报告 100%。聚焦的插桩语料库运行不会记录其子进程中的包源码;配对名单检查证明该 spec 不在插桩清单中,但存在于无插桩清单中。 - -八子进程语料库运行检查相同的 239 个原生 Windows bundle,得到 234 个精确 export 匹配、四个固定 loader 豁免、一次 sentinel 拒绝和零漂移。ARM64 虚拟机上,分片 Vitest 路径耗时 25.44 秒,未分片检查器耗时 29.59 秒;完整 x64 job 仍负责证明宿主争用下的耗时。 - ## Consequences - 豁免套件在执行时不会向阈值门禁叠加插桩开销;分区墙钟数据由 [job 内分区决策](2026-08-18-in-job-partitioned-coverage.zh.md)负责记录。 -- 原生 Windows 在插桩覆盖率后调度豁免套件,并让它们与观测性检查重叠;Linux 保留并行覆盖率拆分。 -- 语料库套件使用最多 8 个非空 Node loader 子进程,但只产生一个阻断性测试结果;其亲和名单属于豁免判定器,受影响 bundle 移动时必须同步更新。 - `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。 - 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。 - 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index ea0f2ca087..78f432ef3b 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: d3b37bdcc19b7b06aee2aa4a067cfd317617ab67 -2026-08-08-native-windows-pull-request-ci.zh.md: 01edfece893d1d6f4cbb14a7d061e372313327f3 +2026-08-08-native-windows-pull-request-ci.md: 1f8bf7c9e5249ce218fd0d169ed82008c2dbbd36 +2026-08-08-native-windows-pull-request-ci.zh.md: efe044e601aebc92f5d9446a1a683c935dcd783b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index d3b37bdcc1..1f8bf7c9e5 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) ## Problem -The pull-request Windows verdict needs both a fast win32 toolchain signal and a real Windows-kernel result. Wine provides the fast signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. +The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator. @@ -14,13 +14,13 @@ A coverage audit found that stale branch state had restored temporary exclusions The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts a separate `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. +Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. -The native job retains its own unmasked result. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes that result a dependency of `all checks passed`; this note owns the job's execution topology and complete inventory. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. +The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -The 16-core lane admits eight concurrent outer gates. Workspace build, production-site validation, and sixteen-process instrumented coverage start immediately. Exempt-heavy coverage needs the build and waits for the merged coverage verdict, so its four Vitest workers and up to eight corpus children do not compete with the partition phase. The lightweight observational inventory also waits for coverage, then overlaps the exempt work; temporary package probes are atomically published with scanner-valid contents, while script-only probes use hidden filenames. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, and instrumented coverage start immediately. Exempt-heavy coverage waits for the build to pass, so its temporary Oxlint contract probes cannot race source compilation. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`. The initial phase therefore has about ten active execution units; after build, starting exempt-heavy while build leaves keeps the peak near eleven when site and instrumented coverage are still running. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits, while separate single-worker child processes retain process isolation. Sixteen-shard samples and the final hosted run complete instrumented coverage in 112.66–131.33 seconds; the job gives that phase the host before starting exempt work. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. @@ -36,6 +36,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Alternatives considered +**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path. + **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. **Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. @@ -48,7 +50,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine preserves a fast early signal and its stable job identity. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes `all checks passed` wait for both Wine and native Windows, so branch protection consumes their combined verdict through one stable required check. +Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 01edfece89..efe044e601 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求的 Windows 判定同时需要快速的 win32 工具链信号与真实 Windows 内核结果。Wine 提供快速信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 +拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。 @@ -14,13 +14,13 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个单独的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 -原生作业保留自身未被掩盖的结果。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让该结果成为 `all checks passed` 的依赖项;本文负责该作业的执行拓扑与完整清单。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 +原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 -16 核通道最多同时运行 8 道外层门禁。工作区构建、生产网站验证与 16 进程插桩覆盖率会立即启动。豁免重型覆盖率依赖构建并等待覆盖率报告合并,因此其 4 个 Vitest worker 与最多 8 个语料库子进程不会和分区阶段争用资源。轻量观测性清单同样等待覆盖率,随后与豁免工作重叠;包内临时探针会以满足源码扫描要求的完整内容原子发布,只属于脚本的探针则使用隐藏文件名。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证与插桩覆盖率会立即启动。豁免重型覆盖率等待构建通过后再启动,使其临时 Oxlint 约定探针不会与源码编译竞态。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker。因此初始阶段约有 10 个活动执行单元;构建结束并启动豁免重型门禁后,如果网站与插桩覆盖率仍在运行,峰值约为 11 个。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出,而相互独立的单 worker 子进程保留进程隔离。16 分片样本与最终托管运行会在 112.66–131.33 秒内完成插桩覆盖率;作业会先把宿主资源交给该阶段,再启动豁免工作。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 @@ -36,6 +36,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 曾考虑的替代方案 +**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 + **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 **将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 @@ -48,7 +50,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 保留快速的早期信号与稳定作业身份。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让 `all checks passed` 同时等待 Wine 与原生 Windows,因此分支保护通过一个稳定的必需检查采用二者的合并判定。 +Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index d8ee642db9..b48f880c61 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 8d8684b6299f4c2dc359ced09048d0b6acb9ed42 -2026-08-18-in-job-partitioned-coverage.zh.md: 4781b5e0abb8dc4dde8004b08f5a5f105ef50b80 +2026-08-18-in-job-partitioned-coverage.md: 532c145f5b66bd6574f9ee167c12c739fd4d7fa9 +2026-08-18-in-job-partitioned-coverage.zh.md: dc8a089a7c089338b775e49fdfe67f134f077704 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 8d8684b629..532c145f5b 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -12,13 +12,13 @@ The optimization must retain every test and the merged per-file 100% thresholds. ## Decision -The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 16; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate. +The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 8; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work. When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker and one `--shard=/` option. Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process. The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Build, production-site validation, and instrumented coverage start immediately on native Windows. The exempt gate needs the build and waits for instrumented coverage to settle, so its full-corpus children and temporary Oxlint probes do not compete with the sixteen partitions; it then receives four workers from the budget of 12. The observational inventory also waits for instrumented coverage, then overlaps the exempt gate within an eight-worker outer budget. Ordering uses `after`, so both groups still run after an instrumented failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. Build, production-site validation, and instrumented coverage start immediately; exempt-heavy coverage starts only after build passes, preventing its temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -30,9 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al `scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures. -Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds. Sixteen is the fixed Windows count. The exempt gate waits for their merged verdict, so the partition phase overlaps only build and production-site validation: at most eighteen active execution units on a 16-core runner, rather than adding exempt workers to that peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. - -The native ARM64 VM runs the full transform corpus in 29.59 seconds without coverage partitions and in 25.44 seconds through the eight-child Vitest path. A concurrent self-hosted x64 job stretched the former serial test to 279.13 seconds while one instrumented partition reached 442.45 seconds. The Windows graph separates the partition and exempt phases before applying its fixed sixteen-way coverage fan-out. +Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds, but the sixteen-way schedule could put more than twenty active execution units beside build and exempt coverage on a 16-core runner. Eight partitions keep separate-process isolation while accepting a longer feedback path for a materially lower peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. ## Alternatives considered @@ -40,18 +38,14 @@ The native ARM64 VM runs the full transform corpus in 29.59 seconds without cove **Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently. -**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's sixteen-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. +**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. **Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report. -**Overlap the Windows exempt gate with instrumented partitions.** Rejected because the full-corpus checker is fast in isolation but multiplies under partition contention. The post-coverage phase uses available workers for the exempt and observational checks without changing either verdict. - ## Consequences Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution. Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. -Windows uses two resource phases inside the same job: sixteen isolated coverage processes through the merged threshold verdict, then the four-worker exempt gate beside lightweight observational checks. - Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 4781b5e0ab..dc8a089a7c 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 16;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁。 +普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 8;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁与插桩工作并排运行。 启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker,并各自接收一个 `--shard=/` 选项。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 上的构建、生产网站验证与插桩覆盖率会立即启动。豁免门禁要求构建通过,并等待插桩覆盖率结算,因此其完整语料库子进程和临时 Oxlint 探针不会与十六个分区争用资源;随后它从 12 的预算中获得 4 个 worker。观测性清单也等待插桩覆盖率,然后在八 worker 的外层预算内与豁免门禁重叠。该顺序使用 `after`,因此插桩失败后两组检查仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -30,9 +30,7 @@ Status: implemented `scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。 -已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒。Windows 固定使用 16 个分区。豁免门禁等待其合并判定,因此分区阶段只与构建和生产网站验证重叠:16 核运行器上最多有 18 个活动执行单元,不会再把豁免 worker 加入该峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 - -原生 ARM64 虚拟机在没有覆盖率分区时用 29.59 秒运行完整转换语料库,通过八子进程 Vitest 路径时用 25.44 秒。一个并发运行的自托管 x64 job 把此前的串行测试拉长到 279.13 秒,同时一个插桩分区达到 442.45 秒。Windows 门禁图先分离分区阶段与豁免阶段,再应用固定的 16 路覆盖率扇出。 +已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒,但 16 路调度与构建、豁免覆盖率并行时,会在 16 核运行器上形成超过 20 个活动执行单元。8 个分区继续保留独立进程隔离,同时接受更长的反馈路径,以显著降低峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 ## 曾考虑的替代方案 @@ -40,18 +38,14 @@ Status: implemented **提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 -**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 16 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 +**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 **在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。 -**让 Windows 豁免门禁与插桩分区重叠。** 不予采用,因为完整语料库检查器在独立运行时很快,却会在分区争用下成倍变慢。覆盖率后的阶段把可用 worker 用于豁免检查与观测性检查,不改变任何一项判定。 - ## 后果 每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。 Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 -Windows 在同一个 job 内使用两个资源阶段:十六个隔离的覆盖率进程先产出合并阈值判定,随后四 worker 的豁免门禁与轻量观测性检查并排运行。 - 未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。 diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md deleted file mode 100644 index ddec9536cb..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Native Windows blocks the pull-request aggregate - -Status: implemented - -English | [中文](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md) - -## Problem - -Wine reaches blocking win32 toolchain paths quickly, but it cannot prove behavior that depends on the NT kernel, NTFS, PowerShell, Windows process control, or native addons. An `all checks passed` result that can succeed while the complete native job is pending or failed does not enforce the repository's supported Windows behavior. - -The native job runs the complete supported-source coverage denominator and its owning Windows acceptance inventory. Its optimized 16-core hosted run completes within the five-minute target, making that higher-fidelity result short enough for the required pull-request path. - -## Decision - -The `all-checks-passed` job in [ci.yml](../../../../.github/workflows/ci.yml) lists both `windows` and `windows-native` in `needs`. Its existing `if: always()` verdict treats a failed, cancelled, or skipped native job like any other unsuccessful dependency, so `all checks passed` cannot succeed until the real-Windows job succeeds. - -Branch protection continues to require the single stable `all checks passed` context rather than adding the native job name as another protected context. The [dual Windows topology](2026-08-08-native-windows-pull-request-ci.md) owns each job's host, failover selector, and inventory; this note owns their blocking relationship. The aggregate bookkeeping job follows the Linux failover selector for its own runner while `needs` independently waits for the pool selected by `DSH_CI_FAILOVER_WINDOWS`. - -## Alternatives considered - -**Keep native Windows informational.** This preserves the shortest aggregate path, but permits a merge while the highest-fidelity supported Windows verdict is pending or red. - -**Require `windows node 24 / native complete` directly in branch protection.** This duplicates workflow topology in repository settings and makes a job-name change a control-plane migration. The aggregate already provides one stable required context and fails closed over unsuccessful dependencies. - -**Remove Wine from the aggregate.** Native Windows provides higher fidelity, but Wine still returns a faster win32 build and production-site signal, preserves the compatibility topology, and gives maintainers earlier failure evidence while the native inventory runs. - -## Consequences - -Every merge waits for native Windows runner capacity and for the complete native job to finish. A failure, cancellation, or skip in that job makes `all checks passed` fail; a passing Wine job alone is insufficient. - -The workflow remains one pull-request Action with one native Windows job, unchanged test coverage, and unchanged gate semantics inside that job. The required aggregate gains the native job's measured duration without adding a separately managed branch-protection context. diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md deleted file mode 100644 index 94fa8b3836..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 原生 Windows 阻断拉取请求聚合流程 - -Status: implemented - -[English](2026-08-22-native-windows-blocks-pull-request-aggregate.md) | 中文 - -## 问题 - -Wine 能快速触达阻断性 win32 工具链路径,但无法证明依赖 NT 内核、NTFS、PowerShell、Windows 进程控制或原生插件的行为。如果 `all checks passed` 能在完整原生作业仍处于待处理或失败状态时成功,它就没有强制验证仓库所支持的 Windows 行为。 - -原生作业会运行完整的受支持源码覆盖率分母及其所属 Windows 验收清单。优化后的 16 核托管运行能在五分钟目标内完成,因此这项保真度更高的结果足够短,可以进入必需的拉取请求路径。 - -## 决策 - -[ci.yml](../../../../.github/workflows/ci.yml) 中的 `all-checks-passed` 作业会在 `needs` 中同时列出 `windows` 与 `windows-native`。其现有的 `if: always()` 判定会像处理其他未成功依赖项一样处理失败、取消或跳过的原生作业,因此真实 Windows 作业成功前,`all checks passed` 无法成功。 - -分支保护继续要求单一且稳定的 `all checks passed` 检查,而不把原生作业名称添加为另一个受保护检查。[Windows 双通道拓扑](2026-08-08-native-windows-pull-request-ci.zh.md)负责每个作业的宿主、故障转移选择器与清单;本文负责二者的阻断关系。聚合记账作业为自身运行器采用 Linux 故障转移选择器,而 `needs` 会独立等待 `DSH_CI_FAILOVER_WINDOWS` 所选池中的作业。 - -## 曾考虑的替代方案 - -**让原生 Windows 只提供信息。** 这会保留最短的聚合路径,但也允许在保真度最高的受支持 Windows 判定仍处于待处理或红灯状态时合并。 - -**在分支保护中直接要求 `windows node 24 / native complete`。** 这会在仓库设置中复制工作流拓扑,并使作业名称变更成为控制面迁移。现有聚合流程已经提供一个稳定的必需检查,并会对未成功的依赖项快速失败。 - -**从聚合流程移除 Wine。** 原生 Windows 的保真度更高,但 Wine 仍能更快返回 win32 构建与生产网站信号、保留兼容性拓扑,并在原生清单运行期间更早地为维护者提供失败证据。 - -## 后果 - -每次合并都会等待原生 Windows 运行器容量与完整原生作业结束。该作业失败、取消或跳过都会使 `all checks passed` 失败;仅 Wine 作业通过并不足够。 - -工作流仍然是单个拉取请求 Action,并保留一个原生 Windows 作业、不变的测试覆盖率以及该作业内不变的门禁语义。必需聚合流程会增加原生作业的实测时长,但无需新增单独管理的分支保护检查。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f02c2919f..f9d4cac33a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,10 +307,10 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # The pull-request Windows signals cover complementary hosts. The two fast - # win32 toolchain surfaces (workspace build, production site) execute with - # real, checksum-verified Windows Node under Wine on standard hosted Linux. - # The windows-native job below keeps the complete native-kernel inventory — + # The required pull-request Windows signal: the two blocking win32 surfaces + # (workspace build, production site) execute with real, checksum-verified + # Windows Node under Wine on standard hosted Linux. The independent + # windows-native job below keeps the complete native-kernel inventory — # including the observational portability gates this lane does not run — # on real Windows. This job only provisions runner state (caches, # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same @@ -397,12 +397,13 @@ jobs: if: always() run: wineserver -k 2>/dev/null || true - # Every pull request also gets a real Windows-kernel signal. Its unmasked - # conclusion is a dependency of all-checks-passed, so failure, cancellation, - # or omission blocks the required verdict. Under normal operation it runs on - # the hosted larger runner. DSH_CI_FAILOVER_WINDOWS=selfhosted retargets it - # onto the in-house self-hosted Windows pool. Dependabot PRs are excluded - # from the self-hosted pool and stay queued for the hosted runner — see the failover + # Every pull request also gets a real Windows-kernel signal. This job keeps + # its own unmasked conclusion but is deliberately absent from + # all-checks-passed.needs, so it never delays or changes that required + # verdict. Under normal operation it runs on the hosted larger runner; under + # Windows failover (DSH_CI_FAILOVER_WINDOWS=selfhosted) it retargets onto the + # in-house self-hosted Windows pool. Dependabot PRs are excluded from the + # self-hosted pool and stay queued for the hosted runner — see the failover # runbook. This Windows switch is independent of the Linux # DSH_CI_FAILOVER_LINUX variable that retargets the three required Linux jobs # and the all-checks-passed verdict above. @@ -416,16 +417,12 @@ jobs: name: windows node 24 / native complete timeout-minutes: 120 env: - # Partitioned coverage finishes before the heavy uninstrumented gate; - # the latter can use four workers without competing with sixteen shards. - DSH_COVERAGE_MAX_WORKERS: '12' - DSH_COVERAGE_PARTITIONS: '16' + DSH_COVERAGE_MAX_WORKERS: '6' + DSH_COVERAGE_PARTITIONS: '8' # Instrumented process and polling fixtures can exceed Vitest's defaults # under the complete lane's concurrent gate load. DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' - # After the threshold merge, the heavy gate overlaps lightweight - # observational checks within this post-coverage worker budget. - DSH_GATE_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '4' DSH_PUBLINT_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -461,10 +458,10 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`, including both the Wine `windows` job and the real-kernel - # `windows-native` job. (`needs` cannot reach across workflow files; the - # master-only jobs in ci-master.yml are intentionally not part of this PR - # verdict.) + # `needs`. The required Wine job is listed as `windows`; `windows-native` is + # deliberately absent so its independent result never delays or changes this + # verdict. (`needs` cannot reach across workflow files; the master-only jobs in + # ci-master.yml are intentionally not part of this PR verdict.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including @@ -475,15 +472,14 @@ jobs: # provisioning — and under Linux failover it follows the same selector as # the worker jobs it aggregates, so a standard-hosted outage cannot strand # the branch-protection verdict either. It retargets with the Linux switch - # (DSH_CI_FAILOVER_LINUX), not the Windows one, because this bookkeeping job - # itself runs on Linux; the native dependency resolves its Windows pool - # independently. + # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the + # required Linux workers and runs on the vm-backup pool. runs-on: >- ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows, windows-native] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 961b038e2d..9c6eb14a09 100644 --- a/docs/config-catalog.i18n.yaml +++ b/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: 1fdc23a72f8cf1af49c5b02c11c6861fad1c16af -config-catalog.zh.md: e59c647462711504ba1f420a73813db5e921f334 +config-catalog.md: bbcfbef6dc98f8739c825bca0b2807ca58a1d144 +config-catalog.zh.md: f1db1c5b7d78ee32fd9a0e0d22bcdf8ebb17abb1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1fdc23a72f..bbcfbef6dc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -944,18 +944,16 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } ``` Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e59c647462..f1db1c5b7d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -946,18 +946,16 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } ``` 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index b8a9b43bd1..8ba88343ec 100644 --- a/docs/module-graph.i18n.yaml +++ b/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: d70aa9a7704a7de5b669928a6cafd8358fb2a3b0 -module-graph.zh.md: 2333d71e61bd935fa482fc766bb7d96bb75d56db +module-graph.md: aeb35195f3a095b9de694f164dc1111acf5c8197 +module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b diff --git a/docs/module-graph.md b/docs/module-graph.md index d70aa9a770..aeb35195f3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -439,18 +439,6 @@ flowchart TD pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_brand - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -470,14 +458,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_llm_pi_ai --> pkg_attachment - pkg_llm_pi_ai --> pkg_authorization - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_launch_environment - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -653,6 +633,28 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_authorization + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_fs + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_launch_environment + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions @@ -1696,14 +1698,12 @@ 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), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`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) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`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) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1745,6 +1745,8 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`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), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2333d71e61..ef3bc3fbce 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -441,18 +441,6 @@ flowchart TD pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_brand - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -472,14 +460,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_llm_pi_ai --> pkg_attachment - pkg_llm_pi_ai --> pkg_authorization - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_launch_environment - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -655,6 +635,28 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_authorization + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_fs + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_launch_environment + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions @@ -1698,14 +1700,12 @@ 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), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`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) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`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) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1747,6 +1747,8 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`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), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 5bbdecb070..195f6acaf3 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/attachment.md -attachment.md: 1a8c01ca88f6cf5f252838e3a269f5465dcfcf2a -attachment.zh.md: 97f3bb2d62b60b63484f3e612fd175b9649bbcdc +attachment.md: aa29c5fc6c011f31fbdb3d4fb9f218d0801e5205 +attachment.zh.md: 9ee90794d28f7cfa0d7b30986aa33d6cbc298f8f diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 1a8c01ca88..aa29c5fc6c 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -125,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -176,6 +176,14 @@ abstract saveImage(input: SaveImageAttachment): Promise */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +/** + * Locate the provider-owned normalized object in the harness host filesystem. + * @param ref - durable normalized attachment reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +imageHostPath(ref: ImageAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 97f3bb2d62..9ee90794d2 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -125,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -176,6 +176,14 @@ abstract saveImage(input: SaveImageAttachment): Promise */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +/** + * Locate the provider-owned normalized object in the harness host filesystem. + * @param ref - durable normalized attachment reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +imageHostPath(ref: ImageAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 94fe570a6f..7b592e60fa 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md -filesystem.md: c95c430c39f15f9cea7435b5d18c0caf9ddb964e -filesystem.zh.md: ecbb9e83cf428cb5d17d814f299c7c008fffc4d4 +filesystem.md: 06fb92f453f8a09f87aee817ab56ef147be9a91f +filesystem.zh.md: ea5b6f7a0d6f45b0d7960298ea10417c0b6c2dd3 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index c95c430c39..06fb92f453 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -12,7 +12,7 @@ Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types. Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path. -Consumers that share the filesystem's execution world obtain cross-capability coordinates through the provider instead of interpreting that identity: `processPath(target)` returns the canonical absolute path a subprocess can open, `fileUrl(target)` returns its provider-platform `file:` URI, and `contains(parent, child)` tests canonical identity or descendant containment. +Consumers that share the filesystem's execution world obtain cross-capability coordinates through the provider instead of interpreting that identity: `processPath(target)` returns the canonical absolute path a subprocess can open, `processPathFromHostPath(hostPath)` maps an absolute harness-host file only when that execution world shares it, `fileUrl(target)` returns its provider-platform `file:` URI, and `contains(parent, child)` tests canonical identity or descendant containment. ```ts type-equiv /** @@ -275,7 +275,7 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, and `editText`. `dsh-fs-observation-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `processPathFromHostPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, and `editText`. `dsh-fs-observation-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. @@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P */ abstract processPath(target: FsTarget): string +/** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ +processPathFromHostPath(hostPath: string): string | undefined + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index ecbb9e83cf..ea5b6f7a0d 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -12,7 +12,7 @@ 每个操作首先将用户提供的路径解析为不透明的后端目标。消费方可以显示 `displayPath`,但禁止解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 -与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径,`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI,`contains(parent, child)` 则检查规范化身份相等或后代包含关系。 +与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径;`processPathFromHostPath(hostPath)` 只在该执行世界共享相应宿主文件时映射其绝对路径;`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI;`contains(parent, child)` 检查规范化身份相等或后代包含关系。 ```ts type-equiv /** @@ -275,7 +275,7 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`processPathFromHostPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**。它通过 `fs/*` 事件门禁添加策略,根据未见、缺失或存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取、写入或编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 @@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P */ abstract processPath(target: FsTarget): string +/** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ +processPathFromHostPath(hostPath: string): string | undefined + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d3f211267a..173e05729d 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 4ad7af5673f3894d86af72b04db365fcc0f36608 -llm-streaming.zh.md: ff62bccdac018ea57b58d5edec9b7dae448be76a +llm-streaming.md: bdc830a5d387cde6967575551ec9b0a9b2626f46 +llm-streaming.zh.md: b602336bc06cd88a2634f5259eff117da3dcd986 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4ad7af5673..bdc830a5d3 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -30,6 +30,18 @@ interface ContentBlockMap { The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. + +Source: [`packages/llm/llm/src/content.ts`](../../packages/llm/llm/src/content.ts) + +```ts type-equiv +/** Execution-world path that model tools can use to read one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) A `Message` is one identified, immutable role/source/content value. Model-produced assistant messages name the provider and model that produced them and carry optional adapter-private replay data in their source: diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ff62bccdac..b602336bc0 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -30,6 +30,18 @@ interface ContentBlockMap { 各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 + +源码:[`packages/llm/llm/src/content.ts`](../../packages/llm/llm/src/content.ts) + +```ts type-equiv +/** Execution-world path that model tools can use to read one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + 源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) `Message` 是一个带标识且不可变的角色/来源/内容值。模型生成的 assistant 消息会在来源中记录生成它的提供方和模型,以及可选的适配器私有回放数据: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e0946004aa..3e8f3aa982 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,7 +16,6 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -807,16 +806,36 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(result.stderr).toBe('') expect(requests).toHaveLength(2) expect(fileRequests).toEqual([{ method: 'POST', path: '/files', bytes: 69 }]) + const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' + const attachmentId = `sha256:${attachmentDigest}` + const accessText = (cwd: string): string => { + const attachmentPath = join( + cwd, + '.dsh', + 'attachments', + 'v1', + 'objects', + attachmentDigest.slice(0, 2), + attachmentDigest, + ) + return ` Normalized copy (read-only; may be resized or re-encoded): ${JSON.stringify(attachmentPath)} (1x1px, image/png).` + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .png before editing.' + } + const normalizedAccess = accessText(result.cwd) + const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` + const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` + const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` + .replaceAll(result.cwd, '{{cwd}}') const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ { type: 'text', text: 'Compare the older image ' }, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImage }, { type: 'text', text: ' with the newer image ' }, { type: 'text', - text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'request image 1x1px.', + text: `\n${imageHandle}`, }, { type: 'file', file_id: 'file-api-snapshot-1' }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, @@ -839,7 +858,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests 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.`, + content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`, }, { role: 'user', @@ -860,7 +879,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests role: 'tool', tool_call_id: 'native-read-image', content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; request image 1x1px.', + + `\n${normalizedToolImageHandle}`, }, { role: 'user', @@ -891,15 +910,12 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(requests).toHaveLength(3) const fallbackMessages = requests[2]?.messages as { content?: unknown }[] | undefined const fallbackInput = fallbackMessages?.find(message => JSON.stringify(message.content).includes('[image omitted')) + const fallbackAccess = accessText(fallback.cwd) expect(fallbackInput?.content).toEqual([ { type: 'text', text: 'Compare the older image ' }, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` }, { type: 'text', text: ' with the newer image ' }, - { - type: 'text', - text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'request image 1x1px.', - }, + { type: 'text', text: `\nImage ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ]) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 545e201707..2828e95d6a 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: 5f7447aad383a3b5c5d08bb78351471b61a3fc2b -README.zh.md: b58e352bc0401dc486181788c959d70b3c52a1a7 +README.md: dd779f5e9af7d97d871134296ac95d5536649b22 +README.zh.md: 0c92838c91b2e72eb80b34c0b9780f685642ceed diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 5f7447aad3..dd779f5e9a 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,25 +2,24 @@ 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. Writes 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. +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. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`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`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. `imageHostPath` derives the normalized object's absolute host path and does not inspect the tool execution world. At request assembly, an LLM consumer asks the mounted filesystem to map that host object into its execution world. A host-backed filesystem returns a process path; a remote filesystem without a shared mount returns no path. The mapped path is absent from durable history and from `RequestImageAttachment`. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through durable replay of historical user images and structured model image output after restart and fork. +Indirectly, through request descriptors. When the current execution filesystem maps this backend's host object, the model receives each retained or offloaded image's identity, dimensions, media type, read-only mapped path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect -Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. +Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. Execution-world path mapping is resolved separately and can change historical descriptor text without changing those bytes or their `variantId`. ## 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 sources keep only their first frame; animation is outside the version-one image contract. - The normalization and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future normalized attachments or request variants while existing objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index b58e352bc0..0c92838c91 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,25 +2,24 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。`imageHostPath` 派生规范化对象的绝对宿主路径,不检查工具执行环境。组装请求时,LLM 消费方要求当前文件系统把该宿主对象映射到其执行环境。宿主文件系统返回进程路径;没有共享挂载的远程文件系统不返回路径。映射后的路径不进入持久历史,也不进入 `RequestImageAttachment`。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。 +该包通过请求描述间接影响模型。当前执行文件系统能够映射本后端的宿主对象时,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、映射后的只读路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 -规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。 +规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。执行环境路径单独解析;它的映射变化会改变历史描述文本,但不会改变请求字节或 `variantId`。 ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 -- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 - 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 - 规范化和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的规范化附件或请求变体产生新地址,已有对象保持有效。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index f615bbb187..59467107a8 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -15,7 +15,7 @@ import type { import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' -import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { canPassThroughNormalization, normalizeImage } from './normalization.ts' @@ -210,6 +210,10 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } + override imageHostPath(ref: ImageAttachmentRef): string { + return normalizedImagePath(this.root, ref) + } + override async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, @@ -233,12 +237,15 @@ export class LocalAttachmentStore extends AttachmentStore { operation = undefined } if (operation === undefined) { - const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile( - this.root, - stored ?? await this.readImage(ref, sharedSignal), - policy, - sharedSignal, - ))) + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => { + const request = await readRequestImageFile( + this.root, + stored ?? await this.readImage(ref, sharedSignal), + policy, + sharedSignal, + ) + return request + })) operation = shared this.requestInflight.set(key, shared) void shared.promise.finally(() => { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 5fbb8e9201..e5a979aec1 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -36,16 +36,23 @@ function displayName(value: string | undefined): string | undefined { return clean === '' ? undefined : clean } -function objectPath(root: string, sha256: string): string { - return join(root, 'objects', sha256.slice(0, 2), sha256) -} - function ensureReference(ref: ImageAttachmentRef): string { const match = ID_PATTERN.exec(String(ref.attachmentId)) if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') return match[1] } +/** + * Derive the absolute immutable-object path for one normalized attachment. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable normalized attachment reference. + * @returns provider-local path without reading the object. + */ +export function normalizedImagePath(root: string, ref: ImageAttachmentRef): string { + const sha256 = ensureReference(ref) + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], @@ -199,7 +206,7 @@ export async function commitPreparedImageFile( await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = objectPath(root, sha256) + const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) @@ -215,13 +222,18 @@ export async function commitPreparedImageFile( const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(temporary) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. + await chmod(target, 0o400) // Persist the target entry and close a concurrent bucket-creation window // before the reference can reach a session checkpoint. The dedup path // repeats both syncs because it may observe another writer's link before // that writer reaches its own durability boundary. await syncDirectory(bucket) await syncDirectory(join(root, 'objects')) - await unlink(temporary) } catch (error) { /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ if (handle !== undefined) await handle.close().catch( @@ -275,7 +287,7 @@ export async function readImageFile( const sha256 = ensureReference(ref) let data: Uint8Array try { - data = new Uint8Array(await readFile(objectPath(root, sha256), { signal })) + data = new Uint8Array(await readFile(normalizedImagePath(root, ref), { signal })) } catch (error) { signal?.throwIfAborted() if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 4c10fa5033..da0cef1400 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -37,6 +38,21 @@ describe('local attachment service', () => { maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) + const ref = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + expect(service.imageHostPath(ref)).toBe(join( + service.root, + 'objects', + 'aa', + 'a'.repeat(64), + )) + expect(() => service.imageHostPath({ ...ref, attachmentId: AttachmentId('invalid') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) it('resolves and validates the instance image-compression concurrency', () => { @@ -57,6 +73,18 @@ describe('local attachment service', () => { )) const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + const hostPath = service.imageHostPath(ref) + expect(hostPath).toBe(join( + dshHome, + 'attachments', + 'v1', + 'objects', + String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2), + String(ref.attachmentId).slice('sha256:'.length), + )) + await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) + const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) + expect(request).not.toHaveProperty('access') } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index e1a729d90e..d1721ceacd 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -132,12 +132,23 @@ describe('local attachment store', () => { expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { - expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(object)).mode & 0o777).toBe(0o400) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } + await chmod(object, 0o600) + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + if (process.platform !== 'win32') expect((await stat(object)).mode & 0o777).toBe(0o400) await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { + const storageRoot = await root() + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + it('stores the normalized image of an oversized source and reads it back verified', async () => { const storageRoot = await root() const oversized = new Uint8Array(await sharp({ diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index e27f25e933..a2fb7e3452 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: 3ad568c7308f1ab85cb4af3fcc2afd3cba9a611a -README.zh.md: fadbb1c5bbf097c599da651055d63a1ed64cd579 +README.md: 21030a492464aae528d4c06b4b72d4a94c0a2603 +README.zh.md: 0540996f99b3250331e567e174264cf7da8aa474 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 3ad568c730..21030a4924 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing 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 returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing 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 returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation 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. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. When the attachment backend exposes a host object and the current execution filesystem maps it, the descriptor also exposes the resulting read-only path; it states that normalization may have resized or re-encoded the upload. #### KV Cache effect -Adding an image changes the provider request and therefore invalidates the affected request suffix. +Adding an image changes the provider request and therefore invalidates the affected request suffix. A changed execution-world path can also change historical descriptor text without changing the deterministic request version. ## Known Limitations and Deferred Work diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index fadbb1c5bb..0540996f99 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。附件后端给出宿主对象且当前执行文件系统能够映射该对象时,描述还会公开映射后的只读路径,并说明规范化过程可能缩小或重新编码上传图片。 #### KV 缓存影响 -添加图片会改变提供方请求,因此会使受影响的请求后缀失效。 +添加图片会改变提供方请求,因此会使受影响的请求后缀失效。即使确定性的请求版本不变,执行环境路径变化也会改变历史描述文本。 ## 已知限制与待完成工作 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8b54926efa..ccfc3a3f98 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -107,6 +107,17 @@ export abstract class AttachmentStore extends Service { */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + /** + * Locate the provider-owned normalized object in the harness host filesystem. + * @param ref - durable normalized attachment reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ + imageHostPath(ref: ImageAttachmentRef): string | undefined { + void ref + return undefined + } + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index be784f0276..8675d45404 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -146,6 +146,12 @@ describe('AttachmentStore.readImageRequest', () => { controller.abort(reason) expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) + + it('exposes no provider-owned host path by default', async () => { + const store = new RecordingStore(new Context()) + const ref = await store.saveImage(image(1)) + expect(store.imageHostPath(ref)).toBeUndefined() + }) }) describe('isImageAdmissionError', () => { diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9f6a18fd7b..be8a3521ac 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -27,7 +27,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', - 'chat.deepDiving': '正在深入处理…', + 'chat.deepDiving': '深度求索中...', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d6ccc631e6..d112bd779c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -914,7 +914,7 @@ describe('ChatView', () => { const view = render() expect(view.getByTestId('tool-seat-r1')).toBeTruthy() expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) - expect(view.getByRole('status').textContent).toBe('正在深入处理…') + expect(view.getByRole('status').textContent).toBe('深度求索中...') }) it('keeps the Tool renderer mounted when a running call settles into log order', () => { @@ -974,7 +974,7 @@ describe('ChatView', () => { const view = render() // Freshly mounted (as after a reload) yet already past the 15s gate. const status = view.getByRole('status') - expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/) + expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { h.setSession({ queue: [{ @@ -986,7 +986,7 @@ describe('ChatView', () => { text: 'also', }] }) }) - expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/) + expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) }) it('hands each ordered root call to the keyed business-node slot', () => { diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index fa75709ec2..97e549e84b 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -360,6 +360,7 @@ describe('E2BFileSystem identity, metadata, and reads', () => { const outside = await fs.resolve('/outside.ts') expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts') + expect(fs.processPathFromHostPath('/Users/alice/.dsh/attachments/object')).toBeUndefined() expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts') expect(fs.contains(workspace, workspace)).toBe(true) expect(fs.contains(workspace, nested)).toBe(true) diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts index 8ed031ebc3..8f1d745ea9 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts @@ -11,107 +11,23 @@ * exemptions as stale. The gate's own note applies to itself: a gate whose * verdict depends on how it was launched is not a gate. * - * Eight Node-loader processes divide the discovered files, and the union check - * proves that each bundle appears once. The two test-support bundles and the - * ACL/win32-process pair stay in one ordered shard because their pinned loader - * exemptions depend on the same preceding module state as the unsharded - * checker. - * * The corpus is the build output, so this skips on a tree that has none. */ -import { spawn } from 'node:child_process' -import { globSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { expect, test } from 'vitest' const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url)) -const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) -const corpusShards = 8 -const shardAffinity = new Set([ - // client-runtime needs acp-snapshot to establish Vitest's internal state. - 'packages/test-support/acp-snapshot/lib/index.js', - 'packages/test-support/client-runtime/lib/index.js', - // win32-process observes Koffi's duplicate type names after the ACL bundle. - 'packages/sandbox/sandbox-windows-acl/lib/index.js', - 'packages/subprocess/win32-process/lib/index.js', -]) -interface CorpusResult { - readonly output: string - readonly status: number | null - readonly error?: string -} - -/** @returns Built bundle paths in the same stable order as the checker. */ -function discoverBuiltBundles(): string[] { - return [ - ...globSync('packages/*/*/lib/index.js', { cwd: repositoryRoot }), - ...globSync('vendor/*/lib/index.js', { cwd: repositoryRoot }), - ].map(path => path.replaceAll('\\', '/')).sort() -} - -/** @returns Non-empty shards with every bundle assigned once and loader affinity preserved. */ -function partitionBundles(files: readonly string[], count: number): string[][] { - const partitions = Array.from({ length: count }, () => [] as string[]) - files.forEach((file, index) => { - const assigned = shardAffinity.has(file) ? 0 : index % count - partitions[assigned]?.push(file) - }) - return partitions.filter(partition => partition.length > 0) -} - -/** @returns One isolated Node-loader corpus shard. */ -function runCorpusShard(files: readonly string[]): Promise { - return new Promise((resolveResult) => { - let output = '' - let spawnError: string | undefined - const child = spawn(process.execPath, ['--import', 'tsx/esm', runner, ...files], { - cwd: repositoryRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }) - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { output += chunk }) - child.stderr.on('data', (chunk: string) => { output += chunk }) - child.once('error', (reason) => { spawnError = reason.message }) - child.once('close', (status) => { - resolveResult({ - output, - status, - ...spawnError === undefined ? {} : { error: spawnError }, - }) - }) - }) -} - -test('partitions every bundle once while retaining loader-state affinity', () => { - const files = [ - 'packages/example/first/lib/index.js', - ...shardAffinity, - 'packages/example/last/lib/index.js', - ] - const shards = partitionBundles(files, corpusShards) - - expect(shards.every(shard => shard.length > 0)).toBe(true) - expect(shards.flat().sort()).toEqual([...files].sort()) - expect(shards[0]?.filter(file => shardAffinity.has(file))).toEqual(files.filter(file => shardAffinity.has(file))) -}) - -test('every built bundle transforms to the export shape Node loads', async (context) => { - const files = discoverBuiltBundles() - if (files.length === 0) { +test('every built bundle transforms to the export shape Node loads', (context) => { + const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' }) + const output = `${finished.stdout}${finished.stderr}` + if (output.includes('no built bundles found')) { context.skip('the workspace has no build output to sweep') return } - const shards = partitionBundles(files, Math.min(corpusShards, files.length)) - expect(shards.flat().sort()).toEqual(files) - const finished = await Promise.all(shards.map(runCorpusShard)) - const output = finished.map((result, index) => `shard ${String(index + 1)}/${String(shards.length)}:\n${result.output}`).join('\n') // The runner prefixes every finding with '- ', so a failure reads as the // findings themselves rather than as a diff of its whole report. expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('') - for (const result of finished) { - expect(result.error, output).toBeUndefined() - expect(result.status, output).toBe(0) - } + expect(finished.status, output).toBe(0) }, 900_000) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6f7d362aa8..0798c8f31c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -449,6 +449,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the verified bytes and normalized attachment reference.', throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, + { + signature: 'imageHostPath(ref: ImageAttachmentRef): string | undefined', + description: 'Locate the provider-owned normalized object in the harness host filesystem.', + parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], + returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', + throws: ['an AttachmentError when the durable reference is invalid.'], + }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', @@ -763,6 +770,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'target', description: 'the resolved target whose process path is required.' }], returns: 'an absolute path in the backend\'s execution world.', }, + { + signature: 'processPathFromHostPath(hostPath: string): string | undefined', + description: 'Map an absolute path from the harness host into this filesystem\'s execution world when both paths identify the same file. The base provider exposes no mapping; host-backed or explicitly shared backends override it.', + parameters: [{ name: 'hostPath', description: 'absolute path in the harness host filesystem.' }], + returns: 'the process path for the same file, or undefined when this execution world cannot read that host file.', + }, { signature: 'abstract fileUrl(target: FsTarget): string', description: 'Return the canonical `file:` URI for a target in this filesystem\'s execution world. Backends own URI encoding because the host platform may differ from the execution platform.', diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 68d3340327..02d7e4b704 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-local/README.md -README.md: 4d5c42945b86ccdc8b041d9d7f99a067ab9a37f5 -README.zh.md: 51f417f73294b7497563fece168e5a5d44b73421 +README.md: ae6d2f582abfdbdfcf922899ffe485691167c97e +README.zh.md: 161e4ee9e5b3dbd0f7efc64692e10dc522538034 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 4d5c42945b..ae6d2f582a 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior - **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path. -- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. +- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `processPathFromHostPath` accepts absolute host paths because this backend shares the host filesystem, `fileUrl` encodes target paths through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. - **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 51f417f732..161e4ee9e5 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 行为 - **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。 -- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 +- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径。由于该后端共享宿主文件系统,`processPathFromHostPath` 接受绝对宿主路径。`fileUrl` 通过 Node 的平台感知 URL 转换对目标路径编码。`contains` 使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以自行限制保留量。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 - **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 661ef236b8..7c50532a0e 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -114,6 +114,10 @@ export class LocalFileSystem extends FileSystem { return String(target.targetKey) } + override processPathFromHostPath(hostPath: string): string | undefined { + return isAbsolute(hostPath) ? resolve(hostPath) : undefined + } + override fileUrl(target: FsTarget): string { return pathToFileURL(this.processPath(target)).href } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 1977f438b9..b791b424fb 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -59,6 +59,12 @@ describe('registration', () => { await bareFiber.dispose() }) + it('maps only absolute host paths into its process path namespace', () => { + const path = join(dir, 'image.png') + expect(fs.processPathFromHostPath(path)).toBe(path) + expect(fs.processPathFromHostPath('image.png')).toBeUndefined() + }) + it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => { const maxDiffBasisBytes = Math.min( bufferConstants.MAX_LENGTH, diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 5736cd6c81..c001d6a107 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/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/fs/README.md -README.md: 7bde7d4d64005a6bdd0f0ac974bf45e5450e4207 -README.zh.md: c4366dc1805938a7020310f4cf627b9072a75cd5 +README.md: b6255a385daf9185ccbfb45e4d1896ad2e8a2c9e +README.zh.md: e932b3144430618e2ec7458491b26d43c517024f diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 7bde7d4d64..b6255a385d 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, map shared host files, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,12 +17,13 @@ This package owns the Service Definition and provider contract layer of the four ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements twelve primitives. +A backend subclasses `FileSystem` and exposes thirteen primitives. | Member | Semantics | |---|---| | `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | | `processPath(target)` | Return the canonical absolute path that a subprocess in this provider's execution world can open. This is intentionally distinct from opaque `targetKey`. | +| `processPathFromHostPath(hostPath)` | Return this execution world's process path for the same absolute host file when the backend shares it. The base implementation returns `undefined`; host-backed or explicitly mapped backends override it. | | `fileUrl(target)` | Return the canonical `file:` URI in the execution world's platform syntax. The backend, not the host process, owns encoding. | | `contains(parent, child)` | Test canonical identity/descendant containment without exposing or parsing target keys. Both targets come from this provider. | | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | @@ -61,6 +62,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). -- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **Thirteen primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). - **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index c4366dc180..e932b31444 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 +**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、映射共享的宿主文件、检查包含关系、完整或流式读取文本、有界读取原始字节、检查或列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 本包拥有四层文件系统栈中的 Service Definition 和提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md)): @@ -17,12 +17,13 @@ ## 服务 API(`ctx.fs`) -后端继承 `FileSystem` 并实现十二个原语。 +后端继承 `FileSystem` 并公开十三个原语。 | 成员 | 语义 | |---|---| | `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey`、`displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 | | `processPath(target)` | 返回该提供方执行世界中的子进程可以打开的规范化绝对路径。该路径有意与不透明的 `targetKey` 分离。 | +| `processPathFromHostPath(hostPath)` | 当后端共享同一个宿主文件时,返回该文件在当前执行世界中的进程路径。基类返回 `undefined`,宿主后端或显式映射宿主文件的后端负责覆盖。 | | `fileUrl(target)` | 返回采用执行世界平台语法的规范化 `file:` URI。编码由后端而非宿主进程负责。 | | `contains(parent, child)` | 在不公开或解析目标 key 的情况下,检查规范化身份相等或后代包含关系。两个目标都来自该提供方。 | | `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 | @@ -61,6 +62,6 @@ ## 已知限制与延期工作 - **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md)有意延期的工作。 -- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 +- **只有十三个原语**:没有删除、重命名或移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.zh.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 8ecd03c714..e32890d732 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -125,6 +125,19 @@ export abstract class FileSystem extends Service { */ abstract processPath(target: FsTarget): string + /** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ + processPathFromHostPath(hostPath: string): string | undefined { + void hostPath + return undefined + } + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 121901dc95..6fbe1268af 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -88,6 +88,7 @@ describe('FileSystem provider seam', () => { await ctx.plugin(FakeFileSystem) const fs = ctx.fs as FakeFileSystem expect(fs.sandboxMode).toBeUndefined() + expect(fs.processPathFromHostPath('/host/file')).toBeUndefined() fs.files.set('a.txt', 'hi') const target = await fs.resolve('a.txt') expect((await fs.stat(target))?.type).toBe('file') diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 2bf1a55649..2d2b710299 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/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: 5f03254479b81d294944197c55ffec0c59cdc94b -README.zh.md: 463d2de6774c868e7c2daa2e4772c28c8cce9cc0 +README.md: 0570ead15e0c408e838cab7a641293ccdb11a702 +README.zh.md: 7f58ba2b6a9be8f03fdcc4538777889780949057 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5f03254479..0570ead15e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,9 +52,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire 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`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, 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 as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: alpha images try WebP (effort 0) at 85, 75, then 60, and opaque images try JPEG at those qualities; when every quality exceeds 1MiB the smallest output is used. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: alpha images try WebP (effort 0) at 85, 75, then 60, and opaque images try JPEG at those qualities; when every quality exceeds 1MiB the smallest output is used. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by text naming the complete attachment id and actual request dimensions. When the attachment provider exposes a host object and the current filesystem maps it into the tool execution world, the text also includes that read-only path and the matching extension for a writable copy. This access is resolved independently from the deterministic request version and its `variantId`. The descriptor states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed 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.]`. This high-watermark projection avoids changing an old request prefix after every new image. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Each removed image becomes its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only local path. This high-watermark projection avoids changing an old request prefix after every new image. Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests. @@ -115,7 +115,7 @@ 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. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the access currently resolved for that request in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect @@ -123,7 +123,7 @@ Provider tokenization governs exact text and image-token input. Reasoning passba #### KV Cache effect -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 on every reasoned turn. +An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. Deterministic request-image bytes do not make the complete prefix immutable: a changed execution-world path rewrites historical descriptor text even without offload, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these may prevent reuse from the first affected image. Model-route, prompt, schema, history, and image-budget changes have the same suffix effect; reasoning passback appends on every reasoned turn. ### DeepSeek response diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 463d2de677..7f58ba2b6a 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,9 +52,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:透明图片依次尝试质量 85、75、60 的 WebP(effort 0);非透明图片依次尝试这些质量的 JPEG。全部质量档都超过 1MiB 时使用其中最小的产物。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:透明图片依次尝试质量 85、75、60 的 WebP(effort 0);非透明图片依次尝试这些质量的 JPEG。全部质量档都超过 1MiB 时使用其中最小的产物。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,文本还会给出该只读路径,并指出复制到可写路径时应使用的匹配扩展名。该访问方式独立于确定性的请求版本及其 `variantId`。描述也会说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[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.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。每张被移除的图片都有自己的模型可见占位文本,其中包含显示名称或附件 ID;如果当前提供方支持,还会包含规范化尺寸、媒体类型和当前只读本地路径。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。 @@ -115,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有附件句柄和请求预览尺寸。当前执行文件系统能够映射附件提供方的宿主对象时,模型还会收到规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留本次请求解析出的访问方式。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 @@ -123,7 +123,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### KV Cache 影响 -未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在每个含推理的轮次上追加。 +未更改的已组装前缀可以使用 DeepSeek cache 复用,适配器会在 usage 中报告它。确定性的请求图片字节不能保证完整前缀不变:执行环境路径变化会在没有 offload 时改写历史描述,重新上传可能替换 `file_id`,Files 转为 base64 回退也会改变图片表示。这些变化都可能使复用从首张受影响图片起失效。模型路由、提示词、schema、历史和图片上限变化会产生同样的后缀影响;推理回传会在每个含推理的轮次上追加。 ### DeepSeek 响应 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index eba8fbc5a7..49f42e6539 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -57,6 +58,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 086243ad41..5aaad0eff0 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,10 +8,11 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, + ImageAttachmentAccess, LlmModelInfo, LlmProviderInfo, PreparedAdapterCall, @@ -58,12 +59,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } /** @@ -127,6 +126,8 @@ export interface DeepSeekAdapterOptions { resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ resolveAttachments?: () => AttachmentStore | undefined + /** Bridge one attachment reference into the current model-tool execution world. */ + resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined /** Resolve the process-wide upload reuse store. */ resolveFiles?: () => DeepSeekFileStore /** Prepare the official API's plugin-contributed top-level fields for one exact wire request. */ @@ -206,10 +207,9 @@ function collectImageRefs( * @internal */ export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { - let maxPixels: number - if (model.imagePixelBudget !== undefined) maxPixels = model.imagePixelBudget - else if (model.imageDetail === 'low') maxPixels = DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - else maxPixels = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET + const maxPixels = model.imagePixelBudget === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET return { maxPixels, maxBytes: model.imageMaxBytes === undefined @@ -541,6 +541,10 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) + const resolveImageAccess = attachments === undefined + ? undefined + : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) + const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { representation: 'raw', maxBytes: connection.maxRequestFilesBytes, @@ -548,6 +552,7 @@ export class DeepSeekAdapter extends LlmAdapter { byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, byteLength: ref => Math.min(ref.bytes, policy.maxBytes), + placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } const requestImages = attachments === undefined || model === undefined @@ -564,6 +569,7 @@ export class DeepSeekAdapter extends LlmAdapter { body = await serializeRequestWithImages(requestOptions, { representation: { kind: 'base64' }, requestImages, + ...imageAccessOptions, maxRequestImageBytes: connection.maxInlineRequestImageBytes, maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.inlineImageOffloadByteQuantum, @@ -594,6 +600,7 @@ export class DeepSeekAdapter extends LlmAdapter { }, }, requestImages, + ...imageAccessOptions, maxRequestImageBytes: connection.maxRequestFilesBytes, maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index e6faad74ce..0d895a5e3b 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,8 +13,9 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' 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' @@ -151,9 +152,8 @@ const catalogModel: z = z.object({ 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']), - imagePixelBudget: z.number().step(1).min(1), + imagePixelBudget: z.union([z.number().step(1).min(1), 'low']), imageMaxBytes: z.number().step(1).min(1), - imageDetail: z.union(['auto', 'low']), }) export const Config: z = z.object({ @@ -196,6 +196,9 @@ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { const seen = new Set() return (models ?? DEFAULT_MODELS).map((model) => { + if (Object.hasOwn(model, 'imageDetail')) { + throw new Error('llm-deepseek: catalog model imageDetail is no longer supported; use imagePixelBudget') + } if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty') if (model.name !== undefined && model.name.length === 0) { throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`) @@ -225,13 +228,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) } const hasImage = inputModalities.includes('image') - if (!hasImage && (model.imagePixelBudget !== undefined - || model.imageMaxBytes !== undefined || model.imageDetail !== undefined)) { + if (!hasImage && (model.imagePixelBudget !== undefined || model.imageMaxBytes !== undefined)) { throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`) } if (model.imagePixelBudget !== undefined + && model.imagePixelBudget !== 'low' && (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) { - throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be a positive safe integer`) + throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be "low" or a positive safe integer`) } if (model.imageMaxBytes !== undefined && (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) { @@ -248,12 +251,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee inputModalities: [...inputModalities], ...hasImage ? { - imagePixelBudget: model.imagePixelBudget - ?? (model.imageDetail === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), + imagePixelBudget: model.imagePixelBudget === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, - ...model.imageDetail === undefined ? {} : { imageDetail: model.imageDetail }, } : {}, } @@ -438,6 +439,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, + hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), + ref, + ), prepareExtensions: (request) => { const extensions = ctx.get('deepseekLlmApiExtensions') return extensions?.prepare(request) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 3b22967d96..1749991eca 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,8 +6,8 @@ * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { WireImageContentPart, @@ -48,6 +48,8 @@ export interface ImageSerializationOptions { representation: ImageRequestRepresentation /** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */ requestImages: ReadonlyMap + /** Resolve current tool access independently from deterministic request-image versions. */ + resolveImageAccess?: ImageAttachmentAccessResolver /** Positive bound on accumulated represented image bytes. */ maxRequestImageBytes: number /** Maximum represented images in one request. */ @@ -125,12 +127,14 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { /** Describe the exact request preview and its model-callable coordinate system. */ function imageHandle( + ref: ImageAttachmentRef, version: RequestImageAttachment, + resolveAccess: ImageAttachmentAccessResolver | undefined, precededByContent: boolean, ): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(version)}`, + text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version, resolveAccess?.(ref))}`, } } @@ -154,7 +158,7 @@ async function imageParts( type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, } - return [imageHandle(version, precededByContent), image] + return [imageHandle(block.attachment, version, images.resolveImageAccess, precededByContent), image] } /** Convert user or nested tool-result blocks into ordered wire parts. */ @@ -389,10 +393,10 @@ export function serializeRequest( /** * Build one image-capable request while keeping durable bytes out of session - * messages. Oversized oldest images become deterministic text after their + * messages. Oversized oldest images become per-image text after their * exact request-version byte lengths are known and before provider serialization. * @param options - harness request containing image-capable user content. - * @param images - attachment resolver, request bound, and cancellation. + * @param images - request versions, optional current access resolver, and request bounds. * @param defaults - adapter-level thinking defaults. * @returns the fully materialized DeepSeek request body. */ @@ -415,6 +419,7 @@ export async function serializeRequestWithImages( ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, + placeholder: ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), }) const messages: WireMessage[] = [] if (options.system !== undefined) { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5a32a35664..9366c32ce8 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -109,7 +109,7 @@ function attachmentStoreOf( } { const readImageRequest = vi.fn(project) return { - store: { readImageRequest } as unknown as AttachmentStore, + store: { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore, readImageRequest, } } @@ -147,7 +147,7 @@ describe('request image policy', () => { { maxPixels: 640_000, maxBytes: 1024 * 1024 }, ], [ - { id: 'low', imageDetail: 'low' as const }, + { id: 'low', imagePixelBudget: 'low' as const }, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }, ], [ @@ -367,7 +367,7 @@ describe('DeepSeekAdapter against a mock server', () => { role: 'user', content: [ { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request image 1x1px.`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, { type: 'file', file_id: 'file-api-1' }, ], }], @@ -434,7 +434,7 @@ describe('DeepSeekAdapter against a mock server', () => { })) const body = JSON.stringify(server.requests[0]) - expect(body.match(/older images are omitted first/g)).toHaveLength(11) + expect(body.match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(body.match(/"type":"image_url"/g)).toHaveLength(10) }) @@ -600,7 +600,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(body.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, { type: 'file', file_id: 'file-api-1' }, ], @@ -619,7 +619,7 @@ describe('DeepSeekAdapter against a mock server', () => { { id: 'vision-low', inputModalities: ['text', 'image'], - imageDetail: 'low', + imagePixelBudget: 'low', imageMaxBytes: 512_000, }, { @@ -1895,6 +1895,20 @@ describe('plugin registration and config', () => { expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) }) + it('rejects the removed imageDetail model setting through schema and direct construction', async () => { + const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as + LlmDeepSeek.DeepSeekCatalogModel + expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [legacyModel], + })).rejects.toThrow(/imageDetail is no longer supported/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + 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/) @@ -1907,8 +1921,9 @@ describe('plugin registration and config', () => { }) it.each([ - ['imagePixelBudget', 0, /imagePixelBudget must be a positive safe integer/], - ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be a positive safe integer/], + ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], ] as const)('rejects per-model %s=%s', (field, value, message) => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 4617ebdfed..75cb90000b 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -30,6 +30,18 @@ const IMAGE_REF: ImageAttachmentRef = { width: 1, height: 1, } +const HOST_IMAGE_PATH = '/host/.dsh/attachments/objects/aa/object' +const MODEL_IMAGE_PATH = '/model/.dsh/attachments/objects/aa/object' + +class MappedFileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + processPathFromHostPath(hostPath: string): string | undefined { + return hostPath === HOST_IMAGE_PATH ? MODEL_IMAGE_PATH : undefined + } +} class StaticAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { @@ -53,6 +65,10 @@ class StaticAttachmentStore extends AttachmentStore { return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) } + override imageHostPath(_ref: ImageAttachmentRef): string { + return HOST_IMAGE_PATH + } + override readImageRequest( ref: ImageAttachmentRef, _policy: ImageRequestPolicy, @@ -193,6 +209,7 @@ describe('request-level dynamic configuration', () => { { kind: 'sse', events: textEvents }, ]) const { ctx } = await boot(dir, { baseURL: server.url }) + await ctx.plugin(MappedFileSystem) const messages = [createUserMessage({ content: [ { type: 'image', attachment: IMAGE_REF }, @@ -208,7 +225,8 @@ describe('request-level dynamic configuration', () => { 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":"file"/g)).toHaveLength(2) - expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') + expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') + expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH) expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 968ceabdaf..0240a6c22a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -57,7 +57,7 @@ function imageOptions( refs: readonly ImageAttachmentRef[], resolveFileId: FileResolver = fileResolver(), maxRequestImageBytes = 20 * 1024 * 1024, -) { +): ImageSerializationOptions { return { representation: { kind: 'file' as const, resolveFileId }, requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), @@ -367,7 +367,7 @@ describe('image serialization', () => { role: 'user', content: [ { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request image 1x1px`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, { type: 'file', file_id: 'file-api-image' }, { type: 'text', text: 'after' }, ], @@ -392,7 +392,7 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, { type: 'image_url', image_url: { url } }, ], }]) @@ -411,12 +411,41 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { + type: 'text', + text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`, + }, { type: 'file', file_id: 'file-api-image' }, ], }]) }) + it('includes provider-resolved normalized access in a retained image handle', async () => { + const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 } + const images = imageOptions([ref]) + const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment + version.width = 1130 + version.height = 565 + images.resolveImageAccess = () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), images) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [{ + type: 'text', + text: expect.stringContaining('Image "diagram.png"') as string, + }, { type: 'file' }], + }) + expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object') + expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px') + }) + it('rejects an image whose prepared request version is absent', async () => { const ref = imageRef() await expect(serializeMessagesWithImages([createUserMessage({ @@ -546,14 +575,14 @@ describe('image serialization', () => { { role: 'tool', tool_call_id: 'before-system', - content: expect.stringContaining('request image 1x1px') as string, + content: expect.stringContaining('request preview 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'system', content: 'system history' }, { role: 'tool', tool_call_id: 'before-assistant', - content: expect.stringContaining('request image 1x1px') as string, + content: expect.stringContaining('request preview 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'assistant', content: 'assistant history' }, @@ -564,6 +593,10 @@ describe('image serialization', () => { const resolveFileId = fileResolver() const png = imageRef('image/png', 3) const jpeg = imageRef('image/jpeg', 3) + const images = imageOptions([png, jpeg], resolveFileId, 4) + images.resolveImageAccess = ref => ref.mediaType === 'image/png' + ? { readonlyPath: '/tmp/dsh/objects/png' } + : undefined const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ @@ -573,12 +606,15 @@ describe('image serialization', () => { ], source: { kind: 'plugin', plugin: 'test' }, })], - }), imageOptions([png, jpeg], resolveFileId, 4)) + }), images) expect(wire.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { + type: 'text', + text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string, + }, { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, { type: 'file', file_id: 'file-api-image' }, ], @@ -598,7 +634,7 @@ describe('image serialization', () => { }), inlineImageOptions([ref], 80, 40)) const content = wire.messages[0]?.content - expect(JSON.stringify(content).match(/older images are omitted first/g)).toHaveLength(11) + expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) }) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 2f75c10b9e..81b7bb1eca 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../fs/fs" + }, { "path": "../../util/atomic-write" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index a7021eb1cd..6121212098 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 9ef6596490b614a3d4af3dd6b52eb9b9fe87a335 -README.zh.md: 10f366659a38f52f7700c0db7953b983fd0e623a +README.md: 1ae0e641f7ffaebc1c2c1060e143c72a40631396 +README.zh.md: 17bf059ea9bb3fbbb80cadc0c9eefed76af26abd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 9ef6596490..1ae0e641f7 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. The text includes a normalized-object path when the current execution filesystem maps the attachment provider's host object, marks that path read-only, and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and access currently resolved for that request in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect @@ -181,7 +181,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. Crossing the image bound rewrites an early message (the newly offloaded image becomes placeholder text), so reuse ends at that message until the offloaded prefix stabilizes. +Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. Stable attachment identity and request bytes do not make that text immutable: a changed execution-world path rewrites a historical handle even without offload and may prevent reuse from that image. Changing adapter instance, provider, model, or any other upstream request token has the same suffix effect. Crossing the image bound replaces an earlier image with placeholder text, so reuse ends at that message until the offloaded prefix stabilizes. ### Provider response diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 10f366659a..17bf059ea9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。当前执行文件系统能够映射附件提供方的宿主对象时,文本还会给出规范化对象路径,将其标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和本次请求解析出的访问方式。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 @@ -182,7 +182,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### KV Cache 影响 -转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。跨过图片上限会改写较早的一条消息(新被 offload 的图片变为占位文本),复用在该消息处截止,直到被 offload 的前缀稳定。 +转换保留逻辑请求顺序,图片句柄和 offload 占位内容会加入模型可见文本。稳定的附件身份和请求字节不能保证这些文本不变:执行环境路径变化会在没有 offload 时改写历史句柄,并可能使复用从该图片起失效。更改适配器实例、提供方、模型或其他上游请求 token 会产生同样的后缀影响。跨过图片上限会把较早图片替换为占位文本,复用在该消息处截止,直到被 offload 的前缀稳定。 ### 提供方响应 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index d0755b06cc..ce809946be 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 37d4ea48f6..e20b8e0072 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -47,6 +47,7 @@ import { } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, + ImageAttachmentAccess, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, @@ -55,7 +56,7 @@ import type { ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' @@ -93,6 +94,8 @@ export interface PiAiAdapterOptions { auth: PiAiAuthInjection /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined + /** Bridge one attachment reference into the current model-tool execution world. */ + resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined /** * Observe one assistant history message degrading to provider-neutral * conversion because its stored replay state is unusable by this build. @@ -360,10 +363,15 @@ export class PiAiAdapter extends LlmAdapter { } const context = attachments === undefined ? toPiContext(options, undefined, onReplayDegrade) - : await toPiContext({ ...options, signal: watchdog.signal }, attachments, onReplayDegrade, profile.maxRequestImageBytes, { - maxPixels: profile.requestImagePixelBudget, - maxBytes: profile.requestImageMaxBytes, - }) + : await toPiContext({ ...options, signal: watchdog.signal }, { + attachments, + resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), + maxRequestImageBytes: profile.maxRequestImageBytes, + requestImagePolicy: { + maxPixels: profile.requestImagePixelBudget, + maxBytes: profile.requestImageMaxBytes, + }, + }, onReplayDegrade) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 9faf457c9a..4315cc690e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,8 +4,8 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentId, AttachmentStore, @@ -48,6 +48,7 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { async function userContent( blocks: readonly ContentBlock[], requestImages: ReadonlyMap, + resolveImageAccess: ImageAttachmentAccessResolver, ): Promise { const content: (TextContent | ImageContent)[] = [] for (const block of blocks) { @@ -57,7 +58,10 @@ async function userContent( break case 'image': { const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment - content.push({ type: 'text', text: requestImageHandleText(version) }) + content.push({ + type: 'text', + text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment)), + }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -67,7 +71,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(block.content, requestImages) + const nested = await userContent(block.content, requestImages, resolveImageAccess) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -170,17 +174,29 @@ function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: st return piContext(options, messages) } +/** Inputs that bind deterministic request images to one current tool execution world. */ +export interface PiImageRequestContext { + /** Durable provider that resolves request-image bytes and provider-owned host objects. */ + attachments: AttachmentStore + /** Resolve current tool access separately from deterministic request-image versions. */ + resolveImageAccess: ImageAttachmentAccessResolver + /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */ + maxRequestImageBytes?: number + /** Route pixel and raw encoded-byte budgets. */ + requestImagePolicy?: ImageRequestPolicy +} + /** * Convert text-only harness history to a synchronous pi-ai Context. Tool * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @param attachments - absent; selects the synchronous conversion. + * @param images - absent; selects the synchronous conversion. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @returns the pi-ai context; `tools` is omitted when the request declares none. */ export function toPiContext( options: GenerateOptions, - attachments?: undefined, + images?: undefined, onReplayDegrade?: (reason: string) => void, ): PiContext /** @@ -190,47 +206,42 @@ export function toPiContext( * oldest images are replaced by text placeholders until the request fits, so * an image-heavy session keeps clearing gateway request-size caps. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @param attachments - durable byte resolver for image references. + * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. - * @param maxRequestImageBytes - request-level bound on base64-encoded image payload; omission leaves every image in place. - * @param requestImagePolicy - route pixel and raw encoded-byte budgets. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( options: GenerateOptions, - attachments: AttachmentStore, + images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy?: ImageRequestPolicy, ): Promise export function toPiContext( options: GenerateOptions, - attachments?: AttachmentStore, + images?: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy?: ImageRequestPolicy, ): PiContext | Promise { - return attachments === undefined + return images === undefined ? textOnlyContext(options, onReplayDegrade) - : toPiContextWithImages(options, attachments, onReplayDegrade, maxRequestImageBytes, requestImagePolicy) + : toPiContextWithImages(options, images, onReplayDegrade) } async function toPiContextWithImages( options: GenerateOptions, - attachments: AttachmentStore, + images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy: ImageRequestPolicy = { +): Promise { + const { attachments, resolveImageAccess, maxRequestImageBytes } = images + const requestImagePolicy = images.requestImagePolicy ?? { maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, - }, -): Promise { + } assertSupportedImageRoles(options.messages) const requestMessages = offloadRequestImagesWithPolicy(options.messages, { representation: 'base64', ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), + placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), }) const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { @@ -238,6 +249,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, + placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), }) const toolNames = new Map() const messages: PiMessage[] = [] @@ -260,7 +272,7 @@ async function toPiContextWithImages( } // user role: text + tool results (each result becomes its own message). const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, requestImages) + const content = await userContent(regular, requestImages, resolveImageAccess) const results = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) @@ -268,7 +280,7 @@ async function toPiContextWithImages( messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { - const resultContent = await userContent(result.content, requestImages) + const resultContent = await userContent(result.content, requestImages, resolveImageAccess) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 846509f8c6..c9752b764e 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -57,8 +57,9 @@ import type { Context } from '@deepseek-ai/cordis' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { authContextFrom, credentialStoreFrom } from './auth.ts' @@ -197,6 +198,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, auth, resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, + hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), + ref, + ), onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn( `llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";` diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index b86f1d93ca..21d5b2c486 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, @@ -31,6 +31,18 @@ const IMAGE_REF: ImageAttachmentRef = { width: 1, height: 1, } +const HOST_IMAGE_PATH = '/host/.dsh/attachments/objects/aa/object' +const MODEL_IMAGE_PATH = '/model/.dsh/attachments/objects/aa/object' + +class MappedFileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + processPathFromHostPath(hostPath: string): string | undefined { + return hostPath === HOST_IMAGE_PATH ? MODEL_IMAGE_PATH : undefined + } +} async function harness(baseURL: string, overrides: Record = {}): Promise { vi.stubEnv('PI_TEST_KEY', 'test-key') @@ -228,7 +240,7 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) - it('resolves an attachment service mounted after the adapter when dispatching an image', async () => { + it('resolves attachment and filesystem services mounted after the adapter when dispatching an image', async () => { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) const ref: ImageAttachmentRef = { @@ -281,6 +293,10 @@ describe('PiAiAdapter provider routing', () => { return readImage(value) } + override imageHostPath(_ref: ImageAttachmentRef): string { + return HOST_IMAGE_PATH + } + override readImageRequest( value: ImageAttachmentRef, policy: ImageRequestPolicy, @@ -296,6 +312,7 @@ describe('PiAiAdapter provider routing', () => { providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) await ctx.plugin(LateAttachmentStore) + await ctx.plugin(MappedFileSystem) const result = await assemble(ctx, { provider: 'openai', @@ -311,6 +328,7 @@ describe('PiAiAdapter provider routing', () => { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024, }, expect.any(AbortSignal)) + expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH) expect(server.paths).toEqual(['/v1/responses']) }) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 026ca2c608..4e6e29c118 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -6,9 +6,10 @@ import type { ImageRequestPolicy, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' +import { CallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' +import type { PiImageRequestContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { @@ -43,11 +44,18 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore } const attachments = projectionStore() +function imageContext( + store: AttachmentStore, + overrides: Partial> = {}, +): PiImageRequestContext { + return { attachments: store, resolveImageAccess: () => undefined, ...overrides } +} + function request(messages: GenerateOptions['messages']): GenerateOptions { return { provider: 'openai', @@ -138,7 +146,7 @@ describe('pi-ai request context conversion', () => { { type: 'image', attachment: ref }, ], }]), - ]), attachments) + ]), imageContext(attachments)) expect(context.messages).toEqual([ { role: 'user', content: '', timestamp: 0 }, @@ -174,6 +182,27 @@ describe('pi-ai request context conversion', () => { ]) }) + it('uses the shared normalized-path description for retained images', async () => { + const named = { ...ref, name: 'chart.png', width: 2048, height: 1024 } + const store = projectionStore(value => Promise.resolve({ + ...requestImage(value, Uint8Array.of(1)), + width: 1130, + height: 565, + })) + const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), imageContext(store, { + resolveImageAccess: () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }), + })) + expect(context.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('Image "chart.png"') as string }, + { type: 'image' }, + ], + }) + expect(JSON.stringify(context.messages[0])).toContain('/tmp/dsh/objects/aa/object') + expect(JSON.stringify(context.messages[0])).toContain('request preview 1130x565px') + }) + it('recursively converts nested tool-result text and images', async () => { const callId = CallId('nested-call') const context = await toPiContext(request([user([{ @@ -191,7 +220,7 @@ describe('pi-ai request context conversion', () => { content: [{ type: 'image', attachment: ref }], }, ], - }])]), attachments) + }])]), imageContext(attachments)) expect(context.messages).toEqual([{ role: 'toolResult', @@ -245,14 +274,14 @@ describe('pi-ai request context conversion', () => { }]), user([{ type: 'image', attachment: sized }, { type: 'text', text: 'newer' }]), user([{ type: 'image', attachment: sized }]), - ]), store, undefined, 8) + ]), imageContext(store, { maxRequestImageBytes: 8 })) expect(context.messages).toEqual([ { role: 'toolResult', toolCallId: 'shot-call', toolName: 'unknown', - content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + content: [{ type: 'text', text: offloadedImageText(sized) }], isError: false, timestamp: 0, }, @@ -288,12 +317,12 @@ describe('pi-ai request context conversion', () => { const context = await toPiContext(request([user([ { type: 'image', attachment: old }, { type: 'image', attachment: recent }, - ])]), projectionStore(readImageRequest), undefined, 4) + ])]), imageContext(projectionStore(readImageRequest), { maxRequestImageBytes: 4 })) expect(context.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImageText(old) }, { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, { type: 'image' }, ], @@ -302,12 +331,34 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) + it('uses independently resolved access when exact encoded bytes require offload', async () => { + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const access = { readonlyPath: '/tmp/dsh-normalized-image' } + const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({ + ...requestImage(value, Uint8Array.of(1, 2, 3, 4)), + })) + + const context = await toPiContext(request([ + user([{ type: 'image', attachment: sized }]), + ]), imageContext(projectionStore(readImageRequest), { + maxRequestImageBytes: 4, + resolveImageAccess: () => access, + })) + + expect(context.messages).toEqual([{ + role: 'user', + content: offloadedImageText(sized, access), + timestamp: 0, + }]) + expect(readImageRequest).toHaveBeenCalledTimes(1) + }) + it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const exact = await toPiContext(request([ user([{ type: 'image', attachment: sized }]), user([{ type: 'image', attachment: sized }]), - ]), attachments, undefined, 8) + ]), imageContext(attachments, { maxRequestImageBytes: 8 })) expect(exact.messages).toEqual([ { role: 'user', @@ -327,10 +378,10 @@ describe('pi-ai request context conversion', () => { const store = projectionStore(readImageRequest) const oversized = await toPiContext(request([ user([{ type: 'image', attachment: { ...ref, bytes: 300 } }]), - ]), store, undefined, 8) + ]), imageContext(store, { maxRequestImageBytes: 8 })) // All-text content collapses to the string form; the placeholder still reaches the model. expect(oversized.messages).toEqual([ - { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 }, + { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 }, ]) expect(readImageRequest).not.toHaveBeenCalled() }) @@ -342,16 +393,19 @@ describe('pi-ai request context conversion', () => { Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) )) const store = projectionStore(readImageRequest) - const aliased = await toPiContext(request([user([shared, shared])]), store, undefined, 4) + const aliased = await toPiContext( + request([user([shared, shared])]), + imageContext(store, { maxRequestImageBytes: 4 }), + ) const replayed = await toPiContext(request([user([ { type: 'image', attachment: { ...sized } }, { type: 'image', attachment: { ...sized } }, - ])]), store, undefined, 4) + ])]), imageContext(store, { maxRequestImageBytes: 4 })) const expected = [{ role: 'user', content: [ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImageText(sized) }, { type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], @@ -390,7 +444,7 @@ describe('pi-ai request context conversion', () => { const store = projectionStore(readImageRequest) await expect(toPiContext(request([ history(role, [{ type: 'image', attachment: ref }]), - ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + ]), imageContext(store, { maxRequestImageBytes: 1 }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) expect(readImageRequest).not.toHaveBeenCalled() } @@ -398,7 +452,7 @@ describe('pi-ai request context conversion', () => { history('system', [{ type: 'text', text: 'history system' }]), history('assistant', [{ type: 'text', text: 'answer' }]), user([{ type: 'text', text: 'plain' }]), - ]), attachments)).resolves.toMatchObject({ + ]), imageContext(attachments))).resolves.toMatchObject({ messages: [ { role: 'user', content: 'history system' }, { role: 'assistant' }, diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index ed4df11a4d..6967ea8fd6 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -63,7 +63,11 @@ function attachmentStore(readImageRequest: ( policy: ImageRequestPolicy, signal?: AbortSignal, ) => Promise): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore +} + +function imageContext(attachments: AttachmentStore) { + return { attachments, resolveImageAccess: () => undefined } } describe('toPiContext', () => { @@ -109,7 +113,7 @@ describe('toPiContext', () => { content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], source: { kind: 'plugin', plugin: 'test' }, })], - }, attachmentStore(readImageRequest)) + }, imageContext(attachmentStore(readImageRequest))) expect(readImageRequest).toHaveBeenCalledWith( attachment, @@ -161,7 +165,7 @@ describe('toPiContext', () => { }], source: { kind: 'plugin', plugin: 'test' }, })], - }, attachmentStore(readImageRequest)) + }, imageContext(attachmentStore(readImageRequest))) expect(context.messages).toEqual([{ role: 'toolResult', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 172dbe8a6e..8200210b6d 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../fs/fs" + }, { "path": "../../credentials/credentials" }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 08820bbfd9..bec2fe9b81 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 59c5303bdae8d6391f3bb1595a527d677e2378bc -README.zh.md: 313fc23590de2119bf07dd5c4c4fafe9daa94c56 +README.md: ef58516790a2723bce34aa1bbae2e1629050f6a5 +README.zh.md: 8af1240cdb4f3b65f1c0f841ade620c85443129b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 59c5303bda..ef58516790 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. -Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length. +Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. `resolveImageAttachmentAccess()` separately combines an attachment provider's optional host object with a consumer-supplied mapping from that host path into the current tool execution world. The result never enters `RequestImageAttachment` or its `variantId`. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. @@ -91,11 +91,11 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l ## Model Experience -None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort. +None, as adapters choose when to add the shared image descriptors and per-image placeholders exported by this package, while the LLM service itself only materializes and logs adapter-configured request facts. #### KV Cache effect -Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries. +Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while the optional execution-world path is resolved for each request. A changed path can alter a historical descriptor and prevent reuse from that image even without offload. Crossing a request limit also replaces an older image with per-image text. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 313fc23590..8af1240cdb 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -57,7 +57,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。 -每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度。 +每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。`resolveImageAttachmentAccess()` 单独组合附件提供方可选的宿主对象,以及消费方给出的宿主路径到当前工具执行环境的映射。解析结果不进入 `RequestImageAttachment` 或其 `variantId`。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 @@ -93,11 +93,11 @@ ## 模型体验 -无。服务不添加任何与模型绑定的文本、schema 或消息;它只会填入并记录适配器配置的推理强度。 +无。适配器决定何时加入该包导出的共用图片描述和逐图占位文本,LLM 服务本身只会填入并记录适配器配置的请求事实。 #### KV Cache 影响 -透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。 +推理强度填入不会改变已组装的请求前缀。图片身份和请求预览文本具有确定性,可选的执行环境路径则按请求解析。路径变化会改写历史图片描述,即使没有 offload,也可能使缓存从该图片起无法复用。请求越过上限时,较旧图片也会替换为逐图文本。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 4620275429..ed97a9b6f1 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,15 +2,72 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { assertNever } from './never.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.]' +/** Execution-world path that model tools can use to read one normalized attachment. */ +export interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} + +/** + * Resolve current execution-world access for one durable image reference. + * @param ref - durable normalized attachment reference. + * @returns a read-only execution-world path, or undefined when unavailable. + */ +export type ImageAttachmentAccessResolver = (ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined + +/** + * Bridge one attachment provider's host object location into the mounted + * tool execution world. The consumer supplies the current filesystem + * provider's mapping without making attachment or LLM definitions depend on it. + * @param attachments - provider that owns the normalized attachment object. + * @param mapHostPath - map one absolute host path into the current tool execution world. + * @param ref - durable normalized attachment reference. + * @returns a read-only execution-world path, or undefined when either provider exposes no mapping. + * @throws an attachment error when the durable reference is invalid. + */ +export function resolveImageAttachmentAccess( + attachments: AttachmentStore, + mapHostPath: (hostPath: string) => string | undefined, + ref: ImageAttachmentRef, +): ImageAttachmentAccess | undefined { + const hostPath = attachments.imageHostPath(ref) + if (hostPath === undefined) return undefined + const readonlyPath = mapHostPath(hostPath) + return readonlyPath === undefined ? undefined : { readonlyPath } +} + +function quoted(value: string): string { + return JSON.stringify(value) +} + +function imageIdentity(ref: ImageAttachmentRef): string { + return ref.name === undefined + ? String(ref.attachmentId) + : `${quoted(ref.name)} (${ref.attachmentId})` +} + +function extension(mediaType: ImageMediaType): string { + switch (mediaType) { + case 'image/png': return '.png' + case 'image/jpeg': return '.jpg' + case 'image/webp': return '.webp' + case 'image/gif': return '.gif' + default: return assertNever(mediaType, 'image extension') + } +} + +function normalizedAccessText(ref: ImageAttachmentRef, access: ImageAttachmentAccess): string { + return ` Normalized copy (read-only; may be resized or re-encoded): ${quoted(access.readonlyPath)} (${ref.width}x${ref.height}px, ${ref.mediaType}).` + + ' Source dimensions, format, and byte size may differ.' + + ` Copy to a writable path ending in ${extension(ref.mediaType)} before editing.` +} /** * Stable text shown to a model that cannot accept one durable image reference. - * @param ref - durable master reference omitted from the request. + * @param ref - durable normalized attachment omitted from the request. * @returns deterministic text-only placeholder. */ export function textOnlyImageText(ref: ImageAttachmentRef): string { @@ -19,12 +76,41 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { } /** - * Stable model-facing handle for one exact request image. + * Stable model-facing handle for one exact request image. Identity comes from + * the occurrence's own durable reference: request versions are prepared per + * attachment id, so one shared version may serve occurrences whose display + * names differ. + * @param ref - the occurrence's durable normalized attachment. * @param version - exact request image shown beside the text. + * @param access - optional path resolved for the current tool execution world. * @returns attachment handle and request-image dimensions. */ -export function requestImageHandleText(version: RequestImageAttachment): string { - return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.` +export function requestImageHandleText( + ref: ImageAttachmentRef, + version: RequestImageAttachment, + access?: ImageAttachmentAccess, +): string { + const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.` + return access === undefined + ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.` + : preview + normalizedAccessText(ref, access) +} + +/** + * Stable per-image placeholder for a request-limit omission. + * @param ref - durable normalized attachment omitted from this request. + * @param access - optional provider-resolved path for model tools. + * @returns identity, normalized metadata, and the available recovery path. + */ +export function offloadedImageText( + ref: ImageAttachmentRef, + access?: ImageAttachmentAccess, +): string { + const identity = `image omitted to fit request image limits; ${imageIdentity(ref)}.` + if (access === undefined) { + return `[${identity} No local normalized image path is available; ask the user to attach it again if needed.]` + } + return `[${identity}${normalizedAccessText(ref, access)}]` } /** @@ -57,8 +143,10 @@ export interface RequestImageOffloadPolicy { byteQuantum?: number /** Whether byte accounting uses raw file bytes or inline base64 length. */ representation: 'raw' | 'base64' - /** Resolve the encoded request-version length; omission uses master attachment bytes. */ + /** Resolve the encoded request-version length; omission uses normalized attachment bytes. */ byteLength?: (ref: ImageAttachmentRef) => number + /** Build the model-visible replacement for each omitted attachment. */ + placeholder: (ref: ImageAttachmentRef) => string } /** Collect represented image lengths in request and nested-block order. */ @@ -83,17 +171,18 @@ function collectImageLengths( function replaceOldestImages( blocks: readonly ContentBlock[], remaining: { count: number }, + placeholder: (ref: ImageAttachmentRef) => string, ): 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 }) + next.push({ type: 'text', text: placeholder(block.attachment) }) continue } if (block.type === 'tool-result') { - const content = replaceOldestImages(block.content, remaining) + const content = replaceOldestImages(block.content, remaining, placeholder) if (content !== block.content) { next ??= blocks.slice(0, index) next.push({ ...block, content }) @@ -140,26 +229,6 @@ export function projectImagesForTextModel(messages: readonly Message[]): readonl }) } -/** - * 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[] { - return offloadRequestImagesWithPolicy(messages, { - representation: 'base64', - ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, - byteQuantum: 1, - }) -} - /** * Return a deterministic transient projection whose oldest images are replaced * in whole count and byte quanta after a route budget is exceeded. The target @@ -196,7 +265,7 @@ export function offloadRequestImagesWithPolicy( } const remaining = { count } return messages.map((message) => { - const content = replaceOldestImages(message.content, remaining) + const content = replaceOldestImages(message.content, remaining, policy.placeholder) return content === message.content ? message : { ...message, content } }) } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 6a0eb02c63..3391423bdb 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,18 +1,31 @@ import { describe, expect, it } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { CallId, createUserMessage, - OFFLOADED_IMAGE_TEXT, - offloadRequestImages, + offloadedImageText, offloadRequestImagesWithPolicy, projectImagesForTextModel, + resolveImageAttachmentAccess, + requestImageHandleText, } from '../src/index.ts' -import type { ContentBlock } from '../src/index.ts' +import type { ContentBlock, Message } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } -function image(bytes: number): ContentBlock { +const OMITTED = '[omitted]' + +function offloadBase64(messages: readonly Message[], maxBytes: number | undefined): readonly Message[] { + return offloadRequestImagesWithPolicy(messages, { + representation: 'base64', + ...maxBytes === undefined ? {} : { maxBytes }, + byteQuantum: 1, + placeholder: () => OMITTED, + }) +} + +function image(bytes: number): Extract { return { type: 'image', attachment: { @@ -25,15 +38,15 @@ function image(bytes: number): ContentBlock { } } -describe('offloadRequestImages', () => { +describe('base64 request-image offload', () => { it('preserves every image when no payload bound is configured', () => { const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadRequestImages(messages, undefined)).toBe(messages) + expect(offloadBase64(messages, undefined)).toBe(messages) }) 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) + expect(offloadBase64(messages, 8)).toBe(messages) }) it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { @@ -43,14 +56,14 @@ describe('offloadRequestImages', () => { content: Array.from({ length: 5 }, () => image(rawImageBytes)), source, })] - expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact) + expect(offloadBase64(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 }, + expect(offloadBase64(over, maxRequestImageBytes)[0]?.content).toEqual([ + { type: 'text', text: OMITTED }, ...Array.from({ length: 4 }, () => image(rawImageBytes)), ]) }) @@ -69,12 +82,12 @@ describe('offloadRequestImages', () => { createUserMessage({ content: [shared, image(3)], source }), ] - const fitted = offloadRequestImages(messages, 8) + const fitted = offloadBase64(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 }], + content: [{ type: 'text', text: OMITTED }], }]) expect(fitted[1]?.content).toEqual([shared, image(3)]) expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) @@ -82,8 +95,8 @@ describe('offloadRequestImages', () => { 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 }]) + expect(offloadBase64(messages, 8)[0]?.content) + .toEqual([{ type: 'text', text: OMITTED }]) }) it('keeps unchanged nested content while replacing a later image', () => { @@ -93,9 +106,9 @@ describe('offloadRequestImages', () => { content: [{ type: 'text' as const, text: 'kept' }], } const messages = [createUserMessage({ content: [nested, image(3)], source })] - expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([ + expect(offloadBase64(messages, 1)[0]?.content).toEqual([ nested, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: OMITTED }, ]) }) }) @@ -109,6 +122,7 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxBytes: 128 * mib, byteQuantum: 64 * mib, + placeholder: () => OMITTED, })[0]?.content expect(project(128)?.filter(block => block.type === 'image')).toHaveLength(128) @@ -124,6 +138,7 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxImages: 600, countQuantum: 20, + placeholder: () => OMITTED, }) expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20) expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581) @@ -135,12 +150,160 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxBytes: 3, byteLength: () => 2, + placeholder: () => OMITTED, }) expect(projected[0]?.content).toEqual([ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: OMITTED }, image(100), ]) }) + + it('builds a distinct placeholder from each omitted attachment', () => { + const first = image(3) + const second = image(3) + first.attachment = { ...first.attachment, name: 'first.png' } + second.attachment = { ...second.attachment, name: 'second.png' } + const projected = offloadRequestImagesWithPolicy([ + createUserMessage({ content: [first, second], source }), + ], { + representation: 'raw', + maxBytes: 3, + placeholder: ref => `omitted:${ref.name}`, + }) + expect(projected[0]?.content).toEqual([ + { type: 'text', text: 'omitted:first.png' }, + second, + ]) + }) +}) + +describe('model-facing image access', () => { + it('describes the request preview, immutable normalized path, and source uncertainty', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 4_000, + width: 2048, + height: 1536, + name: 'source "map".png', + } + const access = { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' } + const version = { + variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), + attachment, + data: Uint8Array.of(1), + mediaType: 'image/png' as const, + bytes: 1, + width: 923, + height: 692, + depth: 'uchar' as const, + space: 'srgb' as const, + hasAlpha: true, + } + expect(requestImageHandleText(attachment, version, access)).toBe( + `Image "source \\"map\\".png" (${attachment.attachmentId}); request preview 923x692px.` + + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/.dsh/attachments/v1/objects/bb/object" (2048x1536px, image/png).' + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .png before editing.', + ) + }) + + it('bridges a provider host object only through the mounted filesystem mapping', () => { + const attachment = image(1).attachment + const attachments = { + imageHostPath: () => '/host/.dsh/attachments/object', + } as unknown as AttachmentStore + const mapped = (hostPath: string): string | undefined => hostPath === '/host/.dsh/attachments/object' + ? '/workspace/.attachments/object' + : undefined + expect(resolveImageAttachmentAccess( + attachments, + mapped, + attachment, + )).toEqual({ readonlyPath: '/workspace/.attachments/object' }) + expect(resolveImageAttachmentAccess( + attachments, + () => undefined, + attachment, + )).toBeUndefined() + expect(resolveImageAttachmentAccess( + { imageHostPath: () => undefined } as unknown as AttachmentStore, + mapped, + attachment, + )).toBeUndefined() + }) + + it('names each occurrence from its own reference when one prepared version is shared', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 4_000, + width: 8, + height: 8, + name: 'second.png', + } + const version = { + variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), + attachment, + data: Uint8Array.of(1), + mediaType: 'image/png' as const, + bytes: 1, + width: 8, + height: 8, + depth: 'uchar' as const, + space: 'srgb' as const, + hasAlpha: false, + } + expect(requestImageHandleText({ ...attachment, name: 'first.png' }, version)) + .toContain('"first.png"') + }) + + it('keeps a useful omission identity with and without a local path', () => { + const ref = { + attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), + mediaType: 'image/jpeg' as const, + bytes: 10, + width: 10, + height: 5, + name: 'photo.jpg', + } + expect(offloadedImageText(ref)).toContain('No local normalized image path is available') + expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })).toBe( + `[image omitted to fit request image limits; "photo.jpg" (${ref.attachmentId}).` + + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/object" (10x5px, image/jpeg).' + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .jpg before editing.]', + ) + }) + + it.each([ + ['image/png', '.png'], + ['image/jpeg', '.jpg'], + ['image/webp', '.webp'], + ['image/gif', '.gif'], + ] as const)('names the writable extension for %s', (mediaType, suffix) => { + const ref = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + mediaType, + bytes: 1, + width: 1, + height: 1, + } + expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })) + .toContain(`writable path ending in ${suffix}`) + }) + + it('rejects a media type that escaped the closed union at runtime', () => { + const ref = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + mediaType: 'image/tiff' as unknown as ImageMediaType, + bytes: 1, + width: 1, + height: 1, + } + expect(() => offloadedImageText(ref, { readonlyPath: '/tmp/object' })) + .toThrow('unreachable variant in image extension: "image/tiff"') + }) }) describe('projectImagesForTextModel', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e763188fd0..9aef169a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6183,6 +6183,9 @@ importers: '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../deepseek-llm-api-extensions + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -6232,6 +6235,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 116bb84e3c..56334523aa 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -36,7 +36,7 @@ describe('CI workflow', () => { } }) - it('keeps required Wine and native Windows jobs with failover, plus a master-only standby', () => { + it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml') if (!isRecord(workflow.jobs) @@ -73,7 +73,7 @@ describe('CI workflow', () => { expect(windows.if).toBe("github.event_name == 'pull_request'") expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true) - // windows-native: blocking native job with failover, runs windows-complete. + // windows-native: non-blocking native job with failover, runs windows-complete. // Its pool is resolved by the Windows-specific switch. expect(typeof windowsNative['runs-on']).toBe('string') expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS') @@ -84,10 +84,7 @@ describe('CI workflow', () => { expect(windowsNative.name).toBe('windows node 24 / native complete') expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '12', - DSH_COVERAGE_PARTITIONS: '16', DSH_COVERAGE_TEST_TIMEOUT_MS: '30000', - DSH_GATE_CONCURRENCY: '8', }) const nativeSteps = windowsNative.steps as unknown[] const nativeCommandSteps = nativeSteps.filter((step): step is Record & { run: string } => ( @@ -104,9 +101,9 @@ describe('CI workflow', () => { expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - // Aggregate: both complementary Windows jobs are required. + // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') - expect(aggregate.needs).toContain('windows-native') + expect(aggregate.needs).not.toContain('windows-native') expect(aggregate.needs).not.toContain('serial-windows') // Linux failover is a separate switch: the three required Linux workers diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 2e68b53b60..eff6ca2b13 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -39,10 +39,4 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' }, - // The real corpus transform runs package src only in a spawned Node process, - // outside the parent Vitest worker's v8 coverage session. - { - filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - }, ] diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d8bed47236..db52420840 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -335,6 +335,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', EncodedImageAttachment: 'attachment.md', + ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index c1f39f8cc8..def26f0a78 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -1,8 +1,8 @@ import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative } from 'node:path' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' import { describe, expect, it } from 'vitest' @@ -39,26 +39,6 @@ function normalizedOutput(result: ReturnType): string { return `${result.stdout}${result.stderr}`.replaceAll('\\', '/') } -/** @returns A transient filename excluded from concurrent repository-wide glob discovery. */ -function hiddenProbeName(prefix: string, suffix: string, extension = '.ts'): string { - return `.${prefix}-${suffix}${extension}` -} - -/** - * Publish a complete probe so concurrent repository scans never read a partial write. - * @param path - Final probe path that the owning project must discover. - * @param source - Complete TypeScript source to publish. - */ -async function publishProbe(path: string, source: string): Promise { - const staging = join(dirname(path), `.${basename(path)}.staging`) - try { - await writeFile(staging, source) - await rename(staging, path) - } finally { - await rm(staging, { force: true }) - } -} - async function writeContractConfig(suffix: string): Promise { const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`) await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] })) @@ -79,8 +59,7 @@ describe('Oxlint executable contract', () => { ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'], ['website', 'website', 'tsconfig.host.json'], ] as const - const source = `/** Produce a settled promise for type-aware linting. */ -export function probePromise(): Promise { + const source = `export function probePromise(): Promise { return Promise.resolve() } @@ -91,7 +70,7 @@ probePromise() const paths: Array = [] for (const [label, parent, tsconfig, extension = '.ts'] of probes) { const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`) - await publishProbe(path, source) + await writeFile(path, source) paths.push([label, relative(repositoryRoot, path), tsconfig]) } const clientScript = 'scripts/client-bundle-purity.spec.ts' @@ -109,7 +88,7 @@ probePromise() expect(result.error).toBeUndefined() expect(result.status, output).toBe(1) for (const [label, path, tsconfig] of paths) { - expect(output, label).toContain(`${path.replaceAll('\\', '/')}:6:1: Promises must be awaited`) + expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`) expect(output, `${label} project`).toContain( `Got tsconfig for file ${join(repositoryRoot, path).replaceAll('\\', '/')}: ${join(repositoryRoot, tsconfig).replaceAll('\\', '/')}`, ) @@ -131,7 +110,7 @@ probePromise() it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', hiddenProbeName('oxlint-contract', suffix)) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) const source = `export function firstProbe(): number { const first = 1 const second = 2 @@ -251,7 +230,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('reports an unused suppression', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', hiddenProbeName('oxlint-contract', suffix)) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) try { await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') @@ -301,7 +280,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**') const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, 'export const value={answer:1};\n') const lint = runOxlint([ @@ -324,7 +303,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('preserves successful fix output channels', async () => { const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') @@ -348,7 +327,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('prints only the final diagnostics when a fix retry still fails', async () => { const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index e7188f7709..1535b27ba3 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -169,13 +169,14 @@ describe('gate graph validation', () => { expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build') - expect(byId.get('coverage-exempt-heavy')?.after).toContain('coverage') expect(observational).not.toHaveLength(0) for (const gate of observational) { const completeGate = byId.get(gate.id) expect(completeGate?.allowFailure).toBe(true) - expect(completeGate?.after).toContain('coverage') - expect(completeGate?.after).not.toContain('coverage-exempt-heavy') + expect(completeGate?.after).toEqual(expect.arrayContaining([ + 'coverage', + 'coverage-exempt-heavy', + ])) expect(completeGate?.needs).toEqual(gate.needs) } }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e24e662153..45af4dc99e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -472,12 +472,9 @@ function ciWindowsBlockingGates(): Gate[] { function ciWindowsCompleteGates(): Gate[] { const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy' - ? { - ...gate, - needs: [...new Set(['build', ...(gate.needs ?? [])])], - after: [...new Set(['coverage', ...(gate.after ?? [])])], - } + ? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] } : gate) + const coverageAfter = coverage.map(gate => gate.id) const observational = ciWindowsObservationalGates() // The required production site replaces the observational MPA build; both // VitePress modes write the same output directory and cannot overlap. @@ -485,7 +482,7 @@ function ciWindowsCompleteGates(): Gate[] { .map(gate => ({ ...gate, allowFailure: true, - after: [...new Set(['coverage', ...(gate.after ?? [])])], + after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], })) return [ ciBuildGate(), diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 06cce11143..3bedaa9804 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -36,6 +36,11 @@ "symbol": "ContextFormed", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/subsystems/llm-streaming.md", + "symbol": "ImageAttachmentAccess", + "source": "packages/llm/llm/src/content.ts" + }, { "doc": "docs/subsystems/llm-streaming.md", "symbol": "FinishReasonMap",