dsh-acp 技术文档
August 26, 2026 · View on GitHub
本文档描述 dsh-acp 的实现细节:架构、会话事件到 ACP 的映射、配置机制与能力边界。面向维护者与需要深入理解行为的用户。
架构
dsh-acp 是一个 dsh profile bundle(声明 dsh.bundle.patch),叠加在 dsh-base 上运行。dsh --profile acp 启动后,插件 apply(ctx, config) 在进程 stdin/stdout 上打开一个 ACP AgentSideConnection,通过 ctx.agents 创建/驱动持久 Agent 会话。
flowchart LR Zed -->|ACP JSON-RPC over stdio| ACP[AgentSideConnection] ACP -->|session/*| Bridge[dsh-acp 插件] Bridge -->|ctx.agents create/resume| Agent[dsh Agent] Agent -->|session/event 火线| Bridge Bridge -->|session/update 通知| Zed
- stdout 只承载 ACP 帧;诊断信息走
ctx.logger→ stderr。 - 模型路由、沙箱、审批、持久化、子代理注册表等宿主能力由
dsh-base提供;工具与 persona 来自 agent preset 的 standing mount(见下文)。
会话事件 → ACP 映射
插件订阅 session/event 火线,按事件类型翻译:
| dsh 会话事件 | ACP 输出 |
|---|---|
assistant/chunk(text-delta) | agent_message_chunk(token 逐字流式) |
assistant/chunk(reasoning-delta) | agent_thought_chunk(思考流) |
assistant/message | 兜底:某一步没有流式增量时才回退提交文本,避免重复 |
tool/call | tool_call(kind、位置跳转、原始入参) |
tool/result | tool_call_update(completed/failed,结果文本或结构化 diff) |
tool/call/tool/result(bash/pwsh) | terminal 内容(见「bash 终端」) |
turn/end | 关联到 in-flight prompt 的结束原因,映射为 ACP stopReason |
其他生命周期事件通过以下订阅完成:
agent/inbox/claimed—— 把提交的消息 id 关联到其 turn,用于精确结算。agent/error—— 相关 turn 失败时立即拒绝session/prompt。approval/request—— 把 dsh 的一次性审批(沙箱越权等)以session/request_permission交给客户端(allow_once / reject_once)。
向用户提问(ask_user_question → elicitation)
agent preset(standard / code / cordis)自带 @deepseek-ai/dsh-tool-ask-user,其 ask_user_question 工具会阻塞在 ctx.userQuestions seam 上。桥接层注册该 seam 的 provider,把提问翻译成 ACP 表单 elicitation(elicitation/create,form 模式)——参考 codex-acp 的 elicitation 处理(其 app-server tool/request_user_input 同样映射为 form elicitation):
| dsh 问题形态 | elicitation form 属性 |
|---|---|
| 带选项(单选) | {type: 'string', oneOf: [{const, title}]},选中的 label 回到 selected |
multi_select: true + 选项 | {type: 'array', items: {type: 'string', enum}},多选 labels 回到 selected |
| 无选项(自由文本) | {type: 'string'},输入文本回到 custom |
| 任意选项题 + 自定义 | 附加可选自由文本字段 {id}__other(title Other,description 同 codex:Type your own answer instead of choosing an option above.)——这是 dsh 自定义答案通道(web UI 对任何选项题都提供自定义输入)在 ACP 表单上的呈现 |
- required 语义:无选项题进
required;选项题的主字段不进required——用户可以不选任何选项、只填 Other 输入框(codex 的isOther场景)。{id}__other恒为可选。 - 答案回填(对齐 web UI):单选时 Other 文本取代已选选项(
custom非空则selected清空);多选时selected保留勾选、Other 文本作为custom并存;只填 Other 不选选项 →{selected: [], custom}。未作答的问题(accept 缺字段)直接省略。 - 单问题时
message直接用问题文本,多问题用Input requested (N questions)。 tool/call(ask_user_question)时把 callId 压入记录的 FIFO 队列,elicitation 请求携带toolCallId,让 IDE 把问题模态框挂到已渲染的工具卡片上;tool/result时清理未消费的 callId(工具在提问前报错/被中断的场景)。- 能力门控:客户端在
initialize里声明clientCapabilities.elicitation.form才启用;未声明(或elicitation/create返回 method-not-found)时工具立即失败并给出自解释错误(ELICITATION_UNSUPPORTED:客户端不支持提问,把未决问题或决策并入最终结果),而不是让回合挂起。与 web UI 的语义保持一致:decline/cancel→ASK_CANCELLED,回合取消(abort signal)→ASK_ABORTED。 - 该 capability 在 SDK 0.25.1 中属 UNSTABLE 通道(
unstable_createElicitation),但 wire 方法名elicitation/create与现行规范一致。
斜杠指令
session/new / session/load 后,通过 ctx.commands.list(agent) 枚举该 agent 的 dsh 指令,发 available_commands_update(AvailableCommand = {name, description, input?})。用 setTimeout(0) 延后发送,确保落在 session/new(或 load)响应之后——Zed 会忽略未知 sessionId 的通知。
session/prompt 收到以 / 开头的文本时,先经 ctx.commands.execute(agent, line, images, signal) 分发(images 为随 prompt 携带的原始 base64 图片上传,空数组表示无图片;指令自己完成准入):命中的指令在命令平面执行,不进入模型历史,其结果文本以 agent_message_chunk 回显给客户端,随后回合以 end_turn 结束(不驱动模型回合)。这使 /plan 能真正开启 plan mode——否则 /plan 会被当作普通文本交给模型,导致后续 exit_plan_mode 因「不在 plan mode 中」而失败。未命中或非法 / 文本不是命令,仍按普通模型输入回退。带消息的命令(如 /plan <message>)由处理器自行 agent.steer() 追加模型可见工作,桥接层在响应前等待该回合收敛。
识图(image prompt)
initialize 通告 promptCapabilities.image: true(audio / embeddedContext 仍为 false),Zed 等客户端因此允许在 prompt 中携带 image 内容块。session/prompt 收到图片块时的处理:
- 准入:图片块(
{type: 'image', mimeType, data})先映射成 dsh 的 wire 准入形态EncodedImageAttachment(mediaType+ canonical base64,与浏览器上传端点同一契约),再经共享入口admitEncodedImages(attachments, images)交给 dsh 0.1.1 的持久化附件服务(ctx.attachments,dsh-attachment)——批量限额、媒体类型校验、规范化与顺序提交全部由附件服务执行,返回持久引用(ImageAttachmentRef,形如sha256:…)。 - 错误映射:准入拒绝(
IMAGE_TOO_LARGE等ImageAdmissionErrorCode子集,按code路由、不依赖原型链)→ ACPinvalidParams,且被拒批次不落任何持久对象;存储故障保持内部错误。 - 模型消息:按 prompt 原始块顺序构造用户消息——文本与
resource_link保持文本,每个图片块变成 dsh 的ImageBlock {type: 'image', attachment: ref}(模型侧由 provider 适配器解析成请求版本,如 DeepSeek 的image_urlpart;纯文本模型由 harness 自行投影/替换)。仅图片、无文本的 prompt 也是合法输入。 - 斜杠指令:识别为
/指令时,原始 base64 上传随commands.execute(agent, line, images, signal)交给命令平面——指令自己完成准入(自己的 store/限额检查;不接受图片的指令以错误文本结算,不会静默丢弃上传),桥接层不再重复准入,避免同一批图片落两份对象。
历史回放(session/load)中带图片的用户消息以文本占位符呈现([image: <name/mediaType>, WxH px])——与工具结果里 read_image 的文本信封一致,不静默丢上下文。
结构化 diff
write/edit优先取 dsh 工具自带的tool/result.meta.diffs(已算好 hunk diff)。str_replace_editor无该 meta,故在tool/call时对目标文件做快照、tool/result时再读比对。edit/str_replace_editor的卡片位置按old_string/old_str在编辑前快照中的唯一匹配推断 1-based 行号;歧义(多次匹配)时不带行号。
bash 终端
bash/pwsh 以 terminal 内容呈现:
tool_call:content: [{type:'terminal', terminalId}]+_meta.terminal_info(terminal_id + cwd),命令作为卡片标题,kind: 'execute'。tool_call_update:_meta.terminal_output(完整输出)+_meta.terminal_exit(退出码 + signal)。
输出与退出码优先取 tool/result.meta(card: 'terminal' 时读 meta.output/meta.exitCode/meta.signal);非 terminal 情况(错误/后台任务)回退到渲染文本。
注意:dsh 会话事件里 bash 没有逐块流式输出(仅
tool/call→tool/result两个事件),因此是「执行完一次性填充终端」,非 token 级实时滚动。
上下文占用圆环(usage_update)
客户端状态栏的上下文小圆环由 session/update { sessionUpdate: 'usage_update', used, size } 驱动(ACP UsageUpdate:size = 上下文窗口总 token 数,used = 当前已用 token 数):
- 数据源:
ctx.sessionProjections.snapshot(session).values.contextPressure(dsh-token-meter 的 provider 锚定投影)——used = projectedTokens ?? pressureTokens,size = contextWindow。与 dsh 自身 Web UI 的上下文占用统计同源,压缩(compaction)与模型切换会立即反映,无需等下一次 provider usage 上报。 - 触发时机:
assistant/message、tool/result、turn/end后各推一次;session/load时从持久化投影播种;set_config_option(model)后刷新(模型切换可能改变窗口大小)。 - 静默规则:分子或分母未知(新会话首个请求之前)时不发,圆环在第一次上报后点亮。
todo 列表(plan)
dsh 的 todo_write 工具(agent preset 自带)以整表快照的形式追加 todo/write 会话事件({todos: {content, status}[]},status ∈ pending / in_progress / completed)。桥接层把它翻译成 ACP plan 更新(sessionUpdate: 'plan',稳定 v1 通道,Zed 等客户端将其渲染为任务清单/计划卡片):
{
"sessionId": "...",
"update": {
"sessionUpdate": "plan",
"entries": [
{ "content": "分析现有代码结构", "priority": "medium", "status": "in_progress" }
]
}
}
- 整表替换:每次
todo/write都发送完整列表(ACP 要求每次更新携带全部条目,客户端整体替换),与 Web UI 的todos投影(last-write-wins)语义一致。 - 字段映射:
content、status直接透传(状态词表一致);priority是 ACP 必填字段而 dsh todo 没有优先级,统一填medium。 - 回合边界:
turn/start时发送空entries清空清单——与 Web UI「新回合开始隐藏已完成清单」的行为对齐(turn/end保持清单可见)。仅在确实发送过 plan 之后才发清空,从未写过 todo 的会话不产生多余帧。 - 会话回放:
session/load时对todo/write/turn/start事件做同一折叠(整表覆盖、turn/start 清空),只发一条最终的 plan 更新;折叠结果为 null(从未写过或被 turn/start 清空)则不发送。 - 线格式说明:使用稳定通道
sessionUpdate: 'plan'(扁平entries)而非 UNSTABLE 的plan_update包装格式——Zed 的 ACP 客户端只匹配前者并渲染(后者会被静默忽略)。
会话配置
session/new / session/load 返回 configOptions(Zed 渲染为下拉选择器),从左到右:
| id | category | 来源 |
|---|---|---|
permission | permission | ctx.permissionPresets(read-only / workspace-write / danger-full-access);缺省回退 ctx.sandboxPolicy 三档沙箱模式 |
model | model | ctx.llm.listProviders/listModels;经 installModelSelection 运行时切换 |
thought_level | thought_level | ctx.llm.resolveModelInfo().reasoning.efforts;切换 selectionRef.current.reasoningEffort。无 reasoning 元数据的模型回退到规范级别表;模型未声明 defaultEffort 时选择器额外提供首项 provider-default(显示用,请求守卫会剥离,不向模型发送任何 effort——与 Web UI 的「Provider default」一致),默认值永不回退到 off |
同时返回 models/modes 字段(供非 Zed 的 ACP 客户端使用)。Zed 在 configOptions 存在时会忽略 models/modes,因此所有 Zed 可见的选择器都必须进 configOptions。
Agent preset(部署字段)
preset 是进程级部署字段,不是会话选择器:由 DSH_ACP_PRESET 环境变量(或 acp 行 config.preset)注入,值直接映射到本次被装载的预设 id,不是"必须取 roster 中的 id"这类约束——session/new/session/load 在 factory 的 setup(agentCtx) 里就是按这个值调用 agentPresets.mount(agentCtx, presetId)。该字段可选:不设或为空 → 回退 standard;指定的 id 在任何预设根中都不存在 → UnknownPresetError 报错(信息列出可用预设),会话创建失败。可用 id 包括内置 standard(标准)/ minimal(极简),以及 $DSH_HOME/.agent-presets/<id>/ 下用户自建的预设(用户根默认纳入 roster,includeUserRoot 默认开启);code(PTC)/ cordis(创造)不在 dsh-base 里,需先在 profile 中全局安装对应插件(见下)。
实现:cordis.patch.yml 禁用 23 个宿主平面「模型面向」行(工具、提示段、委派工具),挂载 dsh-agent-presets(默认 standard);CLI 会自动注入随安装的 preset 根目录。session/new/session/load 在 factory 的 setup(agentCtx) 里调用 agentPresets.mount(agentCtx, presetId),整个进程统一用一个 preset 组合。
code(PTC)与 cordis(创造模式)不在 dsh-base 里:它们额外依赖两个宿主平面插件(dsh@0.1.1-rc.2 随安装提供,但由 dsh-web-app 层组合,dsh-base 不含):
code-runtime(@deepseek-ai/dsh-code-runtime-worker-thread)提供codeRuntime:code预设的tool-presentation行(mode: code)通过ctx.inject(["codeRuntime"], …)等待它;缺失时挂载审计通过(非插件级 inject),但 Code Mode 展示静默不生效、退化为 standard。cordis-host-runner(@deepseek-ai/dsh-cordis-host-runner)提供dynamicCordisRunner/cordisInspect:cordis预设的tool-cordis行把它们声明为插件级 inject;缺失时挂载直接失败(N row(s) did not activate: tool-cordis: waiting for …)。
需要这两个模式时,在 $DSH_HOME/profiles/acp/cordis.patch.yml(宿主平面)全局安装对应的两个插件(行 id/name 与 dsh-web-app 组合一致,见该 bundle patch 顶部的注释模板),之后即可像其他预设一样用 DSH_ACP_PRESET=code/cordis 选择。前置条件:dsh ≥ 0.1.1-rc.2(更早版本安装不含这两个包,挂载了也解析不了)。
minimal/standard 不依赖这两个插件。用户自建的预设走同一套规则:预设内只放该会话自己的工具/persona/提示段;若预设需要额外的宿主服务,应把对应行加进 profile 的 cordis.patch.yml(宿主平面),而不是写进预设。
会话历史
session/list→ctx.sessionPersistence.list()枚举持久化会话(cwd +createdAt近似updatedAt,游标分页)。session/load→ 校验持久化 header → 释放同 id 在线会话 →ctx.agents.resume→ 回放转录(user/assistant 消息、工具卡片;todo 历史折叠为一条 plan 更新)后再应答。session/delete→ 释放在线会话 + 删除持久化产物(seam 无删除 API,用locate+rmSync尽力而为),幂等。
能力边界
- 不支持会话 fork(load / list / delete / resume 均已支持)。
- Agent preset 为进程级字段,会话内不可切换。
- 会话列表用
createdAt近似updatedAt,暂不提供标题。 - 仅基线 prompt(文本 +
resource_link+image;音频 / embedded resource 会拒绝)。图片经dsh-attachment持久化准入,见「识图」节。 - 不回传会话标题等(仍属日志/演示层);usage 与 todo 列表已分别通过
usage_update/plan回传。 - 单个
cwd,additionalDirectories不支持;客户端转发来的mcpServers(如 JetBrains AI Assistant)会被接受但忽略——桥不暴露任何 MCP 工具。 ask_user_question依赖客户端elicitation.form能力;不具备时工具报错而不是静默空答(见上)。
目录结构
dsh-acp/
package.json # dsh.bundle.patch 声明 + 依赖 + 仓库元信息
cordis.patch.yml # bundle patch:persona、关闭 HMR、agent 平面迁到 preset、挂载 acp
lib/index.js # ACP 服务端插件(apply/inject)
smoke-test.mjs # 协议冒烟测试(mock 服务,不触模型栈 / $DSH_HOME)
README.md # 英文 README
docs/
README.zh.md # 中文 README
technical.md # 本文档
验证
# 查看组合结果,确认 acp 插件已挂载
dsh --profile acp --dump-config | grep -A4 '"acp"'
# 用 stdio 手工发一帧 initialize(Ctrl-D 结束输入)
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' | dsh --profile acp
# 跑协议冒烟测试(不触模型栈)
node smoke-test.mjs