读完这篇你能:知道
/xxx这类命令是怎么注册的、谁会发现它;理解命令的结果为什么一个 token 都不花;搞清”贴一张图”从输入框到模型之间经过了哪几步;以及 feedback 与 deliverables 这两类”只给人看”的记录挂在哪。前置:34-长驻终端与代码导航。约 35 分钟。
示例代码:dsh-command-kit
DSH 版本:0.2.0-rc.2
前面三十多篇都在给模型加能力。这一篇反过来:给人加能力——把团队里那些”每次都手打一遍”的活,做成一条 / 开头的命令。
这条路上顺带经过三样只在人这一侧出现的东西:贴图、反馈、交付物。它们排在同一篇里,只因为它们是相邻的需求,不是因为有关系。
目标
- 用
ctx.commands.register()注册一条命令,并分清它和工具、skill 的区别。 - 知道命令的语法只规定到”名字”为止,后面全是调用方自己的事。
- 搞清 adapter 是谁,以及为什么 headless 配置里注册了命令却没人能敲。
- 看懂
command/run/command/done这对记录,以及”命令结果不进模型”的确切含义。 - 知道附件的身份、落盘时机与”验证读”。
- 分清 feedback 与 deliverables:谁产生、谁能读、谁看不见。
DSH 设计
先分清动作从哪进来。DSH 把「动作入口」分成互不相通的两种。一种叫「工具」(tool):由模型在对话里自己决定调用,调用与结果都进模型上下文。另一种叫「命令」(command):人在输入框敲 /名字 触发,由一张「注册表」——插件往里登记条目的清单——发现,并针对当前对话直接执行它的处理函数,全程不创建任何模型消息。子系统文档给这张注册表的定位写死了三件事:交互界面用它发现命令,针对确切的对话直接执行,不创建模型消息;返回结果的类型也被定义成「直接呈现给界面」的形态。所以执行一条命令,模型一个 token(模型的计量单位)都不花。这不是调用便宜,是根本没有调用。想让模型知道什么,处理函数得自己把内容投递回对话——注册表里没有通往模型的路。
注册表管得也很少:只管「你是谁、能不能收」,不管你拿输入干什么。命令的语法只解析到名字为止,/ 加一个小写字母开头的名字。后面的一切原文,连分隔空白在内,都原样交给处理函数。参数怎么切、子命令有哪些、随命令贴上来的图片要不要给模型看,全是处理函数自己的事。类型契约里写明:声明了附件的命令「拥有此后的一切语法决定」,附件的模型可见用法「注册表自己从不安排」。
再看贴图走的路。往对话里贴一张图时,字节不许进日志。DSH 为附件留了一条「能力缝」(seam:一类可替换能力的接缝,约定谁定义接口、谁提供实现、谁使用):生产方把字节交给附件服务,字节先落盘,服务才发布一个不可变的「引用」。这个引用的标识由字节内容的摘要决定,不含路径、不含 URL。会话事件与模型可见的消息块里只有这个引用和尺寸等元数据,永远没有 base64 或临时路径。日志因此保持轻小,字节因此可以整体换后端。
但引用不变,不代表对象不变。日志里的引用是不可变的事实,磁盘上的对象却可能被删掉或改掉。权威读接口的契约因此是「读一张图,并验证字节仍然与记录的引用相符」:每次读都重新核对摘要、媒体签名与尺寸。举个具体情形:三个月前的会话里引用过一张截图,后来磁盘清理把那份字节文件删掉了;这时再去读,核对就会失败,而不是把一张坏图当原图交回来。这不是不信任存储后端,而是日志无法替磁盘担保。
最后把「进不进模型」这件事归个档。会话日志里的每条持久事件分两类:产生模型历史的「surface 事件」,与不产生模型历史的「log-only 事件」(依据 local/deepseek-harness-dsh-v0.2.0-rc.2/docs/persistence-catalog.zh.md)。命令的两条执行记录、人对会话或单条回复写下的反馈、一轮交付了哪些文件的宣告,全部属于后者:留在日志里给界面与遥测读,模型历史上永远没有。所以「只给人看」不是这些功能各自的小心,而是同一个分类机制的归类结果。
四件互不相干的事
先把它们的边界摆出来。这四样东西在配置上各自独立,出厂位置也不一样:
| 能力 | ctx 键 |
出厂配置 | 模型看得见吗 |
|---|---|---|---|
| 命令 | ctx.commands |
base 一行;sdk-minimal 没有 |
注册与执行都不进模型;handler 自己投递的内容才进 |
| 附件 | ctx.attachments |
base 一行(本地后端)+ 预设里的 read_image 工具 |
只有变成消息内容才进 |
| 反馈 | ctx.sessionFeedback、ctx.messageFeedback |
/feedback 在 base;逐条评分在 web-app |
从不 |
| 交付物 | ctx.workspaceChanges + present 工具 |
变更记录在 web-app;present 在 standard/cordis/ptc |
从不 |
表格里最后两行的”从不”不是巧合,后面单独说:这两样是给客户端读的,不是给模型读的。
命令就是一张注册表
命令没有”内置”和”插件”两套 API。出厂的那些和你要写的那些,走的是同一个方法:
ctx.commands.register({
definitionId: CommandDefinitionId('dsh-command-kit/brief'),
name: 'brief',
description: 'Draft a one-page brief for a topic (accepts pasted images)',
input: { hint: '<topic> [images]', attachments: true },
recordInput: false,
handler: ({ agent, rawInput, attachments }) => {
// …直接对着 agent 干活,不产生模型消息
return { kind: 'success', text: '已交给模型。' }
},
})
字段各有分工:
| 字段 | 管什么 |
|---|---|
name |
小写、无前导斜杠,^[a-z][a-z0-9_-]*$ |
description |
发现界面里给人看的一句话 |
input.hint |
输入框里的占位符,纯展示 |
input.attachments |
准入开关:不声明(或为 false)时,带附件的调用会在 handler 之前被拒 |
recordInput |
command/run 里要不要记 args,默认记 |
definitionId |
给 adapter 认身份用的稳定标识,与展示文案、与每次执行的 commandId 都无关 |
handler |
返回 success / error,可以带一句给人看的文本 |
register() 返回一个 disposer,但它内部已经把这次注册挂在调用方的 effect 上——出厂的 /feedback 就是直接调、没有外面再包一层 ctx.effect。插件卸载时命令自己消失。
名字冲突分三层,行为不一样:
- 同层重名:注册时直接抛错。报错文案会把”换个 scope 挂”这条路指出来。
- agent 作用域遮蔽全局:不是错误。命令插件被挂在某个 agent 的 ctx 下时,它的注册落在这个 agent 的 scoped layer,同名时最近的层胜。
- 客户端自己加的同名命令:在合成候选项时抛错。
语法只到名字为止
命令的解析规则只有一条正则:
/^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u
也就是:/ 必须在整行的第一个字节,名字以小写字母开头,后接空白或行尾。再往后就没有语法了——rawInput 是从名字后面第一个字符开始的原始切片,包含那个分隔空白。
所以参数怎么切、有哪些子命令,都是你的 handler 自己的事;.trim() 是每个 handler 的第一句话。
另外两条容易误会的:
- 没有别名。 中文界面上
/计划能到/plan、/反馈能到/feedback,那是 Web 客户端按definitionId认出了这几个一方命令之后的本地化 token;只有那六个一方定义同时带中英两套。第三方命令不会有。 - 不支持大写。
/Plan不是命令。
谁会发现命令
ctx.commands 面向 adapter 的接口是三个:list(agent) 拿该 agent 生效的目录、find(agent, name) 拿一份定义、execute(...) 执行。注册或注销会发一条 commands/change,观察者失败会被兜住、不会否决这次变更。
当前出厂的交互前端只有 Web 客户端:它按 session 缓存一份目录,订阅转发过来的 commands/change 之后做软失效——对已经缓存过的会话在后台重拉,旧快照继续服务。这里有个容易写错的点——dsh-commands 的包 README 与一条 Agent Note 都还写着”只有 TUI 消费它”,而这个快照里已经没有 TUI 包了。
接下来是配置上的一个坑:
| profile | 注册表 | adapter |
|---|---|---|
web |
有 | 有 |
headless / sdk / acp |
有(base 挂的) |
没有 |
sdk-minimal |
没有 | 没有 |
也就是说,在 headless 里 --dump-config 只能看到插件那一行——它只列配置行、从不实例化插件,所以连命令目录都形成不了——而没有任何 adapter 去发现或调用那些命令。验证命令必须开界面。
直接调用与两条记录
adapter 把整行交给 execute(agent, line, attachments, signal)。这是唯一的执行入口——list 和 find 只查不跑,出厂代码里也没有”拿到 definition 再自己调 handler”这条路。
一次执行留下的痕迹很轻:
- 铸一个
commandId; - 追加
command/run; - 跑 handler;
- 追加
command/done,带kind与那句给人看的文本。
两条都是 log-only 的独立追加:没有 turn 包着它们,也不会变成 tool result 或 user message。语法不对、名字不认识这两种情况什么都不写,直接返回 undefined。
于是有了这一篇的核心结论:命令结果一个 token 都不花。 发现、执行、UI 上的回执全都进不了模型请求。
代价是:想让模型动起来,handler 必须自己投递。 出厂的 /plan 是个好例子——它先把 plan mode 打开,然后把 rawInput 与附件拼成一条 user message 交给 agent.steer():
if (message !== '' || attachments.length > 0) {
agent.steer(createUserMessage({
content: [
...attachments,
...(message === '' ? [] : [{ type: 'text' as const, text: message }]),
],
source: { kind: 'user' },
}))
}
demo 的 /brief 抄的就是这一段。三个投递动词的差别:steer() 交给最近一步(空闲时会开一轮)、followup() 排一整轮普通后续、inject() 只给下一个 pre-step 塞上下文且不唤醒驱动;inject() 可能错过一个已经认领了自己批次的请求。
还有一条不变量:sourceEventSeq 只能出现在 success 里,且必须指向更早的非命令事件。它的用途是让客户端把命令生命周期与已有的领域事件合成一张卡,而不是让你随便指一个序号。
命令、工具与 skill 的分界
这三样东西都能被”触发”,但站在不同的一侧:
| 谁触发 | 进模型吗 | 典型用途 | |
|---|---|---|---|
| 工具 | 模型自己决定调 | 是——调用与结果都在上下文里 | 给模型加能力 |
| skill | 模型按描述选 | 是——正文按需加载 | 给模型写指导语 |
| 命令 | 人敲 | 否 | 给人的快捷操作 |
选型只有一句话:给模型加能力用工具或 skill(11-为模型注册自定义工具、13-用 Skill 固化提示词与规则),给人加操作用命令。
附件的身份
用户贴一张图,从输入框到模型要过好几道手续。先看身份长什么样:
interface ImageAttachmentRef {
/** 不透明的存储标识;永远不是文件路径,也不是带令牌的 URL。 */
attachmentId: AttachmentId
/** 从落盘字节里验出来的媒体类型。 */
mediaType: ImageMediaType
bytes: number
width: number
height: number
name?: string
/** 只在归一化把图缩小时出现,记的是应用 EXIF 方向之后、缩放之前的尺寸。 */
originalDimensions?: { width: number; height: number }
}
AttachmentId 是 branded 的不透明字符串。本地后端当前发的形式是 sha256:<摘要>,但消费方不许解析它、更不许拿它拼路径——这条写在子系统文档里,不是建议。
然后是时序:先落盘,后发事件。浏览器提交的图片在 user 事件被追加之前就已经移到了本地对象目录;模型侧的结构化图片输出走同一条规矩。会话日志里因此只有引用与元数据,永远没有 base64、临时路径或 provider URL。
读的时候有一道额外的检查。readImage(ref, signal) 的契约是”读一张图,并验证字节仍然与记录的引用相符“——每一次权威读都会重新核对摘要、媒体签名、尺寸与元数据。原因也直白:对象在磁盘上可能被删掉或改掉,而日志里的引用是不变的。
存储本身是一条独立的能力缝:
| 角色 | 包 | 位置 |
|---|---|---|
| 服务定义 | dsh-attachment |
ctx.attachments(抽象类 AttachmentStore) |
| 提供方 | dsh-attachment-local |
base 里那一行,默认落 <DSH_HOME>/attachments/v1/ |
| 消费者 | dsh-tool-fs(read_image)、llm-deepseek、session-controller |
各自只读 |
要留意两件事。一是它不经过 dsh-storage:对象是本地文件,不是 KV 域。二是同一个 scope 里不能有两个 provider(Cordis 对同名服务会抛错),想换后端就得替换掉那一行,或者放进独立的 isolate realm。
AttachmentStore 是导出的抽象类,所以外部插件可以写自己的后端;不过 saveFile / readFileStream / readImageRequest 有默认实现(直接拒绝),只实现你真正要支持的那几个方法就行。
限额也是分层的:图片默认 20 张/条、单张 20 MiB、单条 200 MiB、单张 6400 万像素、单边 8192 像素;归一化再把长边收到 2048、编码字节收到 4 MiB。这些是准入的门槛,比工具读取的门槛松——read_image 还额外要求当前路由声明了图片输入。
命令带的附件
回到命令。当一条命令声明了 input.attachments,adapter 会把这些一起交上来:
type CommandSubmitAttachment =
| ({ readonly type: 'image' } & EncodedImageAttachment) // base64 + 声明的媒体类型
| { readonly type: 'file'; readonly receiptId: string } // 只引用一个已完成上传的回执
注意第二种:通用文件传的是回执 id,不是字节。所以命令提交永远不会再读一遍文件内容。回执的唯一解析者由 registerFileReceiptResolver() 注册——这个方法只能有一个,注册第二个会抛错。
准入由 executor 执行,不是 composer:
- 命令没声明
input.attachments→ 拒; - 没有附件存储 → 拒;
- 回执不认识 → 拒;
- 图片批次超限 → 拒。
四种都在 handler 跑之前变成一条 error 结果。客户端的另一层拒绝(比如不给你贴)只是交互体验,不是边界。
通过之后,handler 拿到的是一个冻结的 invocation.attachments 数组,按用户的选取顺序排列,元素是 ImageBlock | FileBlock——也就是能直接拼进消息内容的形态。它们是否、以什么顺序成为模型可见内容,是 handler 的责任:注册表自己从不安排这件事。
feedback:两份都不进模型
反馈分两条互不影响的通道:
| 会话级 | 消息级 | |
|---|---|---|
| 入口 | /feedback <文本> |
Web 界面给某条回复打分/加注 |
| 服务 | ctx.sessionFeedback.record() |
ctx.messageFeedback 的 list / put / delete |
| 事件 | feedback/record |
feedback/message-put / feedback/message-delete |
| 形状 | 一条不可变备注(text 与 category 都可空) |
一条可改的评分/备注,严格乐观并发 |
两条的共同点比差别更重要:都不进模型历史。这条不只是产品承诺,它还连着遥测的授权口径——默认的遥测模式是”只有显式反馈才允许上传”,判定依据就是这两个事件。
会话级那条还有个细节:/feedback 注册时 recordInput: false,因为 feedback/record 事件自己拥有载荷,日志里不必再存一份。
deliverables:只给客户端看
交付物有两个来源,事件都只有客户端读:
- 模型主动声明:调
present工具,参数是一组{ path, description? }。工具先做校验(必须是已存在的普通文件、必须有打开的 turn 与 cwd),然后在tools/result上确认这次调用成功,才追加deliverables/presented。 - 一轮里被改动的文件:
dsh-workspace-changes监听session/event,从turn/start建记录器、tool/result采集、turn/end结算,追加workspace/changes。
这里有一条容易踩的:workspace/changes 事件只带一个 turn 号。改动清单、行数、每份文件的对比都由 Host 服务在内存里按 (sessionId, seq) 提供——会话释放或 Host 重启之后,旧 turn 的那些对比就没有了。日志里留下的只是”这一轮改过东西”这个事实。
present 的参数描述里写”每次最多 4 个”——那是推荐值;真正生效的上限是配置项 maxFiles,默认 8,源码里那行注释把这两个数分别叫什么写明了。
demo
dsh-command-kit 一个包、两个插件:
// brief.ts:命令 + 附件 + 自己投递
input: { hint: '<topic> [images]', attachments: config.acceptAttachments },
recordInput: false,
handler: ({ agent, rawInput, attachments }) => {
const topic = rawInput.trim()
if (topic === '' && attachments.length === 0) {
return { kind: 'error', text: '用法:/brief <主题> [图片]' }
}
agent.steer(createUserMessage({
content: [...attachments, { type: 'text', text: `${config.instruction}\n\n主题:…` }],
source: { kind: 'user' },
}))
return { kind: 'success', text: '已交给模型。' }
}
// stats.ts:一条只看日志的命令
ctx.on('session/event', (session, event) => {
const counts = countsFor(session.id)
if (event.type === 'command/run') counts.commands += 1
else if (event.type === 'command/done' && event.data.kind === 'error') counts.commandErrors += 1
else if (event.type === 'feedback/record') counts.feedback += 1
else if (event.type === 'deliverables/presented') counts.deliveries += 1
})
第二个插件是这一篇的收口:命令、反馈、交付物三类东西都只写日志,想要一个查询面就只能自己折——而折出来的东西又用一条命令读出来,全程没有模型参与。
验证
cd code/dsh_plugin/dsh-command-kit && ./install.sh
# 1. 两行都在
dsh --profile test --dump-config | grep -n 'command-kit'
# 2. 带界面跑,敲 /brief 主题 → 命令卡回执 + 模型开始写 + 收尾出现交付卡片
dsh --profile test web
# 3. 敲 /kit-stats → 四个计数
# 4. 把 patch.yaml 的 acceptAttachments 改成 false、重装,再贴图敲 /brief
# 这次命令卡上是 error:准入发生在 handler 之前
第 3 步之后可以顺手验一件事:/kit-stats 自己也在被计数(它带 command/run 与 command/done 两条),而模型那一侧看不到任何一条。
注意事项
- 命令不是工具,也不是 skill。 工具与 skill 是模型面的能力;命令是人类面、0 token、永不进模型。给团队做”操作”用命令,给模型写”指导语”用 skill。
- 想让模型动起来,handler 自己投递。 注册表不安排任何模型可见内容。
find()不是 Remote。 浏览器侧插件只能走list与execute;find只在同进程可用。rawInput带着分隔空白。 用之前先.trim()。- 没声明
input.attachments就没有附件。 拒绝发生在 handler 之前,而且是 error 结果,不是静默丢弃。 - 附件拒绝仍然写日志。
command/run在准入之前就追加了,所以一对run+done(error)照样留下;只有语法错误和未知命令才完全不写。 recordInput: false会让command/run丢掉args。 只有当你的领域事件自己拥有载荷时才这么设。- 没有别名。 界面上那几个中文 token 是一方命令的本地化,第三方不会有。
- 附件的 id 不许解析。 日志里只有不透明引用,权威读走
readImage(),每次重验字节。 imageHostPath()只给路径。 能不能读,由当前执行世界的文件系统裁决,不由这个后端负责。- 同一个 scope 里不能有两个 attachment provider。 换后端要么替换那一行,要么用 isolate realm。
- 浏览器不能凭 id 取图。 它必须先证明这个 session 的日志引用过这个 id,再走验证读。
registerFileReceiptResolver()只能注册一个。sourceEventSeq只能指向更早的非命令事件,且只允许出现在success里。- 取消是协作式的。 abort 只让注册表停止等待;handler 不理
signal,它的外部副作用照跑。 - 命令注册表不是可替换的能力缝。 它在 capability-seams 表里属于 core,没有 provider 一列——不要指望换一个命令后端。
workspace/changes不持久。 事件只带 turn 号,对比数据在 Host 内存里。present的 4 是推荐值、8 是默认上限。 参数描述里的”每次最多 4 个”是建议,真正生效的是maxFiles。