mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
refactor(attachment): own prompt admission on service
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -157,6 +181,15 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
*/
|
||||
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
|
||||
|
||||
/**
|
||||
* 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<AdmittedPromptContentPart[]>
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* The returned reference describes the persisted normalized image. When
|
||||
|
||||
@@ -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 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -157,6 +181,15 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
*/
|
||||
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
|
||||
|
||||
/**
|
||||
* 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<AdmittedPromptContentPart[]>
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* The returned reference describes the persisted normalized image. When
|
||||
|
||||
@@ -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<SessionPromptRequest['content'][number], { readonly type: 'file' }>
|
||||
const admitted = await admitPromptContent(
|
||||
ctx.attachments,
|
||||
const admitted = await ctx.attachments.admitPromptContent(
|
||||
content.filter((part): part is NonFilePart => part.type !== 'file'),
|
||||
)
|
||||
let next = 0
|
||||
|
||||
@@ -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<readonly ImageAttachmentRef[]> =>
|
||||
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<Response>) | undefined
|
||||
ctx.provide('connection', {
|
||||
fetch: {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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` 带类型标记的不透明标识符 |
|
||||
| — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 |
|
||||
|
||||
@@ -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<AdmittedPromptContentPart[]> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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<AdmittedPromptContentPart[]> {
|
||||
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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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==' },
|
||||
|
||||
@@ -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<AdmittedPromptContentPart[]>',
|
||||
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<ImageAttachmentRef>',
|
||||
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}',
|
||||
|
||||
@@ -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](
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -357,6 +357,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
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<Record<string, string>> = {
|
||||
ImageAttachmentAccess: 'llm-streaming.md',
|
||||
ImageAttachmentRef: 'attachment.md',
|
||||
ImageRequestPolicy: 'attachment.md',
|
||||
PromptContentPart: 'attachment.md',
|
||||
RequestImageAttachment: 'attachment.md',
|
||||
SaveImageAttachment: 'attachment.md',
|
||||
StoredImageAttachment: 'attachment.md',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user