Command image-attachment envelope
Command image-attachment envelope
The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference p
English
Problem
The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode defaultSink → conversation.sendSession, which serialized the images into prompt content and cleared them on success. A claimed slash command rode claim.submit(args, actx), a text-only transaction: /goal rebuild the cathedral with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest.
Merging the two planes was not on the table — the plugin command registration Agent Note deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork.
Decision
The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly.
Declaration. CommandDefinition.input.images: boolean (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen CommandDescriptor through commands/list to every client, onto the minted CommandClaim (images: true), and into the input machine's published claim snapshot.
Generic identity, image-specific payload. Browser drafts and durable references already use DraftAttachmentId and AttachmentId; the command RPC carries encoded bytes rather than an image identifier. The wire remains EncodedImageAttachment[], and the declaration remains input.images, while images are the only non-text attachment with defined admission and model-block semantics.
Executor enforcement. CommandRuntime.execute(agent, line, images, signal) carries the submission's base64 images (EncodedImageAttachment from @deepseek-ai/dsh-attachment/types). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged command/done error before the handler runs. Admission goes through the attachment package's admitEncodedImages — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to AttachmentStore.saveImages — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered ImageBlocks on invocation.attachments.
Producer-owned model visibility. The registry never schedules the images itself. /goal submits one agent.followup user message — image blocks plus the fixed text Reference images for the goal objective. — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. /plan <message> folds the images into its steered text message, while bare /plan steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (/goal pause, /plan off) return a direct error and keep the composer's images in place. The plan projection treats command/run as a candidate and drops it on a paired command/done error, so a rejected image-carrying /plan off cannot leave a pending exit.
Composer refusal is a visible banner, everything retained. ui-commands' matchEnter receives a SubmitEnvelope (image count) from adjudication and throws a localized notice.imagesUnsupported refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the conversation namespace. On the accepting path the facade serializes the draft images through the hub's commandImages plumbing, passes them to claim.submit, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
Testing
Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in packages/interaction/commands/tests/commands.spec.ts; batch admission ordering and limits in packages/attachment/attachment/tests/admission.spec.ts; producer behavior in packages/goal/command-goal/tests/command-goal.spec.ts and packages/plan/plan-mode/tests/plan-mode.spec.ts; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes.
Alternatives considered
- Block commands whenever images are attached (no acceptance path) — rejected: predictable, but
/goalwith reference images is the motivating use case; the user's images would have no route to the model at all. - Auto-send stranded images as a follow-up user message after any command — rejected: surprising for host-state commands (
/model,/compact), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule. - Store attachment references in the goal domain and render them into round prompts — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility.
- Consume images on any command success regardless of grammar — rejected:
/goal pausewith images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. - Keep enforcement client-side only — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself.
- Generalize the command wire to a multimedia identifier — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining
AttachmentId.
Consequences
- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike.
- The commands package now depends on
dsh-attachmentanddsh-llm, andcommands/executecarries a requiredimageswire parameter — every caller states its envelope explicitly. /goaland/plangain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare/plan; all are billed like any image prompt.- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary.
- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (
/goal pausewith images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written".
中文
Problem
Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 defaultSink → conversation.sendSession,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 claim.submit(args, actx),一个纯文本事务:/goal rebuild the cathedral 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。
合并两个平面从未在考虑范围内——插件命令注册 Agent Note刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。
Decision
提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。
声明。CommandDefinition.input.images: boolean(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 CommandDescriptor 经 commands/list 到达每个客户端,进入铸造出的 CommandClaim(images: true),再进入输入状态机发布的 claim 快照。
**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 DraftAttachmentId 和 AttachmentId;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 EncodedImageAttachment[],声明保持 input.images。
执行器强制。CommandRuntime.execute(agent, line, images, signal) 携带本次提交的 base64 图片(来自 @deepseek-ai/dsh-attachment/types 的 EncodedImageAttachment)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 command/done 错误结算。准入经由 attachment 包的 admitEncodedImages——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 AttachmentStore.saveImages——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 ImageBlock 数组挂在 invocation.attachments 上交给处理器。
**模型可见性由生产方负责。**注册表自身绝不调度这些图片。/goal 在 create 或 edit 成功后通过 agent.followup 提交一条用户消息——图片块加固定文本 Reference images for the goal objective.——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。/plan <message> 把图片并入其 steer 的文本消息;不带参数的 /plan 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(/goal pause、/plan off)会直接返回错误,composer 的图片原地保留。plan 投影会把 command/run 视为候选选择,并在配对的 command/done 报错时丢弃它,因此被拒绝的带图 /plan off 不会留下待退出状态。
**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 matchEnter 从裁决收到 SubmitEnvelope(图片数量),对每条无法消费图片的回车路径抛出本地化的 notice.imagesUnsupported 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 conversation 命名空间的同款文案把关。接受路径上,facade 经 hub 的 commandImages 管道序列化草稿图片、传给 claim.submit,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
Testing
注册表执行器强制、准入失败结算、冻结的调用附件由 packages/interaction/commands/tests/commands.spec.ts 覆盖;批量准入顺序与限额在 packages/attachment/attachment/tests/admission.spec.ts;生产方行为在 packages/goal/command-goal/tests/command-goal.spec.ts 与 packages/plan/plan-mode/tests/plan-mode.spec.ts;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。
Alternatives considered
- 附加图片时一律拦截命令(没有接受路径)——被拒绝:可预测,但带参考图的
/goal正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。 - 任何命令后把滞留图片自动作为后续用户消息发送——被拒绝:对宿主状态命令(
/model、/compact)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。 - 在 goal 领域存储附件引用并渲染进 Round 提示词——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。
- 只要命令成功就消费图片,不管语法——被拒绝:
/goal pause带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 - 只在客户端强制——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。
- 把命令 wire 泛化成多媒体标识——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,
AttachmentId保持不变。
Consequences
- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。
- commands 包新增对
dsh-attachment与dsh-llm的依赖,commands/execute携带必填的imageswire 参数——每个调用方都显式陈述其信封。 /goal与/plan获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的/plan会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。
- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如
/goal pause带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。