From b2c4483c9d327b533a421816bdb06f0c80349bbd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 18:18:32 +0800 Subject: [PATCH] refactor(attachment): own prompt admission on service --- docs/subsystems/attachment.i18n.yaml | 4 +-- docs/subsystems/attachment.md | 35 ++++++++++++++++++- docs/subsystems/attachment.zh.md | 35 ++++++++++++++++++- .../api/session-controller/src/commands.ts | 5 ++- .../tests/commands-upload-file.host.spec.ts | 7 ++-- .../tests/session-models.host.spec.ts | 4 +-- .../attachment/attachment/README.i18n.yaml | 4 +-- packages/attachment/attachment/README.md | 4 +-- packages/attachment/attachment/README.zh.md | 4 +-- .../attachment/attachment/src/admission.ts | 25 ------------- packages/attachment/attachment/src/index.ts | 25 ++++++++++++- packages/attachment/attachment/src/types.ts | 2 +- .../attachment/tests/admission.spec.ts | 18 +++++----- .../extensions/tool-cordis/src/api-catalog.ts | 11 ++++++ packages/subagent/subagent/src/index.ts | 4 +-- .../subagent/subagent/tests/control.spec.ts | 10 +++--- scripts/gen-cordis-catalog.ts | 2 ++ scripts/type-equiv.manifest.json | 10 ++++++ 18 files changed, 150 insertions(+), 59 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index a60f9ab92d..6b849c3679 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: 19c659508936552a449b7b4ee9a085f015ed8c14 -attachment.zh.md: 9942c7a9d2d7b37e10c946c10297cbdada0936c2 +attachment.md: 3fe72b70af27a7597bca8eed24070589c1ba64d7 +attachment.zh.md: db43180565baaf0512cd5dc261324c79dddd1552 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 19c6595089..3fe72b70af 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -62,6 +62,30 @@ The reference records intrinsic dimensions and encoded length so clients can lay ## Commit and verified-read payloads +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +149,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. `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. +`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. `admitPromptContent()` is the Host prompt entry and replaces base64 image uploads with durable references in part order. `admitEncodedImages()` supports other wire entries 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. @@ -157,6 +181,15 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access attachment storage. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly PromptContentPart[], ): Promise + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 9942c7a9d2..db43180565 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -62,6 +62,30 @@ interface ImageAttachmentLimits { ## 提交与经校验读取的数据 +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +149,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 是 Host prompt 入口,按 part 顺序把 base64 图片上传替换为持久引用。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -157,6 +181,15 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access attachment storage. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly PromptContentPart[], ): Promise + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 01afde33e3..2471f0990a 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' @@ -543,8 +543,7 @@ async function durablePromptContent( files.set(part.receiptId, file) } type NonFilePart = Exclude - const admitted = await admitPromptContent( - ctx.attachments, + const admitted = await ctx.attachments.admitPromptContent( content.filter((part): part is NonFilePart => part.type !== 'file'), ) let next = 0 diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 7991a7cee8..acff794b8a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, } from '@deepseek-ai/dsh-attachment' @@ -68,7 +68,10 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }) const saveImages = vi.fn((): Promise => Promise.reject(new Error('fixture did not expect image persistence'))) - ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf( + { saveFile, saveFileStream, saveImages }, + AttachmentStore.prototype, + ) as never) let uploadRoute: ((request: Request) => Promise) | undefined ctx.provide('connection', { fetch: { diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index cd26dfb39d..9fe2bd28bb 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -665,7 +665,7 @@ describe('Web session model selection', () => { const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, } - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: () => { if (saveMode === 'error') return Promise.reject(new Error('image store offline')) if (saveMode === 'remote') { @@ -673,7 +673,7 @@ describe('Web session model selection', () => { } return Promise.resolve([savedRef]) }, - } as never) + }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) const remote = createSessionTestRemote(ctx, { diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 13a3d80930..2aceef5333 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: eab3be50e9dccb2b623336aa96982799f2981299 -README.zh.md: 9f7d6726bc18a7390d20e26ea7d0616949ecbf44 +README.md: 1ab340bbd96881aceaa9ba20ace02fecb2f85052 +README.zh.md: 1fbc9163f202dac32f48d0ca39aa4ebe9559766f diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index eab3be50e9..1ab340bbd9 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers call `ctx.attachments.admitPromptContent()` to replace browser image uploads with durable references while retaining part order. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map @@ -80,7 +80,7 @@ The service family runs one admission-and-storage flow: every entry point enforc |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | -| [`src/admission.ts`](src/admission.ts) | Browser prompt and file admission: canonical-base64 enforcement, store delegation, and durable image-part projection | +| [`src/admission.ts`](src/admission.ts) | Canonical-base64 enforcement and store delegation for encoded image and file uploads | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | — | No runtime invariant companion is published; this stateless seam owns types while implementations enforce immutable-store checks. | diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 9f7d6726bc..1fbc9163f2 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方调用 `ctx.attachments.admitPromptContent()`,按原顺序把浏览器图片上传替换为持久引用。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 @@ -80,7 +80,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | -| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 与文件准入:强制规范 base64、委托存储并投影持久图片 part | +| [`src/admission.ts`](src/admission.ts) | 对编码图片和文件上传强制执行规范 base64 并委托存储 | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 | diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts index a3f5f16c8c..8a5b5ab41b 100644 --- a/packages/attachment/attachment/src/admission.ts +++ b/packages/attachment/attachment/src/admission.ts @@ -4,12 +4,10 @@ import { Buffer } from 'node:buffer' import { AttachmentError } from './error.ts' import type { AttachmentStore } from './index.ts' import type { - AdmittedPromptContentPart, EncodedFileAttachment, EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef, - PromptContentPart, SaveImageAttachment, } from './types.ts' @@ -74,26 +72,3 @@ export async function admitEncodedFile( ...file.name === undefined ? {} : { name: file.name }, }) } - -/** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access the attachment store. - * @param attachments - the deployment attachment store owning batch policy. - * @param content - browser prompt parts in message order. - * @returns admitted prompt parts in the same order as `content`. - * @throws AttachmentError when the image batch is refused. - */ -export async function admitPromptContent( - attachments: AttachmentStore, - content: readonly PromptContentPart[], -): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) -} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 5bff532ac8..1def0c6102 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,12 +1,15 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ import { Context, Service } from '@deepseek-ai/cordis' +import { admitEncodedImages } from './admission.ts' import { AttachmentError } from './error.ts' import type { + AdmittedPromptContentPart, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, + PromptContentPart, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -17,7 +20,7 @@ import type { export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' -export { admitEncodedFile, admitEncodedImages, admitPromptContent } from './admission.ts' +export { admitEncodedFile, admitEncodedImages } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, @@ -98,6 +101,26 @@ export abstract class AttachmentStore extends Service { return refs } + /** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access attachment storage. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ + async admitPromptContent( + content: readonly PromptContentPart[], + ): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) + } + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 03fb45c761..032c7e07c8 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -94,7 +94,7 @@ export interface EncodedImageAttachment { /** * Browser-submitted prompt content accepted by Host prompt endpoints; the * accepting Host promotes image parts to durable references through - * `admitPromptContent` before any message is created, so a wire caller can + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can * never cite an attachment it did not upload. */ export type PromptContentPart = diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 60eadd421f..7951164cd7 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,13 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { admitEncodedFile, admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { admitEncodedFile, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { - const store = { + const mocks = { saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({ attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'], mediaType: input.mediaType, @@ -17,7 +16,8 @@ function storeOf() { ...input.name === undefined ? {} : { name: input.name }, })))), } - return { store: store as unknown as AttachmentStore, mocks: store } + const store = Object.setPrototypeOf(mocks, AttachmentStore.prototype) as AttachmentStore + return { store, mocks } } describe('admitEncodedImages', () => { @@ -103,17 +103,19 @@ describe('admitEncodedFile', () => { }) }) -describe('admitPromptContent', () => { +describe('AttachmentStore.admitPromptContent', () => { it('converts text-only prompts without touching the attachment store', async () => { - const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } - await expect(admitPromptContent(store as unknown as AttachmentStore, [ + const store = Object.setPrototypeOf({ + saveImages: () => { throw new Error('text-only prompts must not reach the store') }, + }, AttachmentStore.prototype) as AttachmentStore + await expect(store.admitPromptContent([ { type: 'text', text: 'hello' }, ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) }) it('replaces image parts with admitted references in part order', async () => { const { store } = storeOf() - await expect(admitPromptContent(store, [ + await expect(store.admitPromptContent([ { type: 'image', mediaType: 'image/png', data: 'AQ==' }, { type: 'text', text: 'between' }, { type: 'image', mediaType: 'image/png', data: 'Ag==' }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1b7bcf71e2..60e7b54d9e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -482,6 +482,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], returns: 'durable normalized attachment references in the same order after every member succeeds.', }, + { + signature: 'async admitPromptContent( content: readonly PromptContentPart[], ): Promise', + description: 'Admit one browser prompt and replace each uploaded image with its durable reference. Text-only prompts do not access attachment storage.', + parameters: [{ name: 'content', description: 'browser prompt parts in message order.' }], + returns: 'admitted prompt parts in the same order as `content`.', + throws: ['AttachmentError when the image batch is refused.'], + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.', @@ -3439,6 +3446,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', }, + { + name: 'AdmittedPromptContentPart', + declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n};', + }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n}', diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 8eb29021ad..4c734db743 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -30,7 +30,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { admitPromptContent } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-attachment' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -446,7 +446,7 @@ export class SubagentRuntime extends TypertRemoteService { } else { const attachments = this.ctx.get('attachments') if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store') - content = await admitPromptContent(attachments, request.content) + content = await attachments.admitPromptContent(request.content) } return { messageId: await this[deliverSubagentPrompt]( diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 733564032b..22db177cc3 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime, { @@ -169,7 +169,7 @@ describe('subagent prompt Remote', () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) => inputs.map((input, index) => ({ ...IMAGE_REF, attachmentId: `att-${index}`, mediaType: input.mediaType }))) - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents).mockResolvedValue('m-content' as MessageId) const content = [ { type: 'text' as const, text: 'before' }, @@ -188,11 +188,11 @@ describe('subagent prompt Remote', () => { it('maps a refused image batch to subagent/attachment-invalid and delivers nothing', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: async () => { throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') }, - } as never) + }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ @@ -207,7 +207,7 @@ describe('subagent prompt Remote', () => { it('maps non-canonical base64 to subagent/attachment-invalid without touching the store', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn() - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 732512cd55..371d6e62cd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -357,6 +357,7 @@ export const LINK_MAP: Readonly> = { ApprovalRequestEvent: 'approval.md', ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', + AdmittedPromptContentPart: 'attachment.md', EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', FileAttachmentRef: 'attachment.md', @@ -365,6 +366,7 @@ export const LINK_MAP: Readonly> = { ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', + PromptContentPart: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', StoredImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a2ad93dd52..c0109a078e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -946,6 +946,16 @@ "symbol": "ImageAttachmentLimits", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "PromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AdmittedPromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "EncodedImageAttachment",