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/chunktext-deltaagent_message_chunk(token 逐字流式)
assistant/chunkreasoning-deltaagent_thought_chunk(思考流)
assistant/message兜底:某一步没有流式增量时才回退提交文本,避免重复
tool/calltool_call(kind、位置跳转、原始入参)
tool/resulttool_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 表单 elicitationelicitation/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/callask_user_question)时把 callId 压入记录的 FIFO 队列,elicitation 请求携带 toolCallId,让 IDE 把问题模态框挂到已渲染的工具卡片上;tool/result 时清理未消费的 callId(工具在提问前报错/被中断的场景)。
  • 能力门控:客户端在 initialize 里声明 clientCapabilities.elicitation.form 才启用;未声明(或 elicitation/create 返回 method-not-found)时工具立即失败并给出自解释错误(ELICITATION_UNSUPPORTED:客户端不支持提问,把未决问题或决策并入最终结果),而不是让回合挂起。与 web UI 的语义保持一致:decline/cancelASK_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_updateAvailableCommand = {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 收到图片块时的处理:

  1. 准入:图片块({type: 'image', mimeType, data})先映射成 dsh 的 wire 准入形态 EncodedImageAttachmentmediaType + canonical base64,与浏览器上传端点同一契约),再经共享入口 admitEncodedImages(attachments, images) 交给 dsh 0.1.1 的持久化附件服务(ctx.attachmentsdsh-attachment)——批量限额、媒体类型校验、规范化与顺序提交全部由附件服务执行,返回持久引用ImageAttachmentRef,形如 sha256:…)。
  2. 错误映射:准入拒绝(IMAGE_TOO_LARGEImageAdmissionErrorCode 子集,按 code 路由、不依赖原型链)→ ACP invalidParams,且被拒批次不落任何持久对象;存储故障保持内部错误。
  3. 模型消息:按 prompt 原始块顺序构造用户消息——文本与 resource_link 保持文本,每个图片块变成 dsh 的 ImageBlock {type: 'image', attachment: ref}(模型侧由 provider 适配器解析成请求版本,如 DeepSeek 的 image_url part;纯文本模型由 harness 自行投影/替换)。仅图片、无文本的 prompt 也是合法输入。
  4. 斜杠指令:识别为 / 指令时,原始 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_callcontent: [{type:'terminal', terminalId}] + _meta.terminal_info(terminal_id + cwd),命令作为卡片标题,kind: 'execute'
  • tool_call_update_meta.terminal_output(完整输出)+ _meta.terminal_exit(退出码 + signal)。

输出与退出码优先取 tool/result.metacard: 'terminal' 时读 meta.output/meta.exitCode/meta.signal);非 terminal 情况(错误/后台任务)回退到渲染文本。

注意:dsh 会话事件里 bash 没有逐块流式输出(仅 tool/calltool/result 两个事件),因此是「执行完一次性填充终端」,非 token 级实时滚动。

上下文占用圆环(usage_update)

客户端状态栏的上下文小圆环由 session/update { sessionUpdate: 'usage_update', used, size } 驱动(ACP UsageUpdatesize = 上下文窗口总 token 数,used = 当前已用 token 数):

  • 数据源ctx.sessionProjections.snapshot(session).values.contextPressure(dsh-token-meter 的 provider 锚定投影)——used = projectedTokens ?? pressureTokenssize = contextWindow。与 dsh 自身 Web UI 的上下文占用统计同源,压缩(compaction)与模型切换会立即反映,无需等下一次 provider usage 上报。
  • 触发时机assistant/messagetool/resultturn/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)语义一致。
  • 字段映射contentstatus 直接透传(状态词表一致);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 渲染为下拉选择器),从左到右:

idcategory来源
permissionpermissionctx.permissionPresets(read-only / workspace-write / danger-full-access);缺省回退 ctx.sandboxPolicy 三档沙箱模式
modelmodelctx.llm.listProviders/listModels;经 installModelSelection 运行时切换
thought_levelthought_levelctx.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 环境变量(或 acpconfig.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)提供 codeRuntimecode 预设的 tool-presentation 行(mode: code)通过 ctx.inject(["codeRuntime"], …) 等待它;缺失时挂载审计通过(非插件级 inject),但 Code Mode 展示静默不生效、退化为 standard。
  • cordis-host-runner@deepseek-ai/dsh-cordis-host-runner)提供 dynamicCordisRunner/cordisInspectcordis 预设的 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 选择。前置条件:dsh0.1.1-rc.2(更早版本安装不含这两个包,挂载了也解析不了)。

minimal/standard 不依赖这两个插件。用户自建的预设走同一套规则:预设内只放该会话自己的工具/persona/提示段;若预设需要额外的宿主服务,应把对应行加进 profile 的 cordis.patch.yml(宿主平面),而不是写进预设。

会话历史

  • session/listctx.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 回传。
  • 单个 cwdadditionalDirectories 不支持;客户端转发来的 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

参考