跳到主要内容
路径文档

ACP 自动化服务器

一句话版@deepseek-ai/dsh-acpstdin/stdout 上实现一个仅面向自动化Agent Client Protocol 服务器(JSON-RPC stdio)。程序化客户端可以创建全新 harness agent、发文本提示、收已提交的 assistant 文本、按策略应答一次性权限请求(无弹窗)、取消在途工作;连接断开时整棵会话树被 drain + dispose,不遗留孤儿 agent。

ACP(Agent Client Protocol)是给"程序化客户端驱动 agent"用的互操作传输。DSH 的 ACP 服务器不是展示层、也不是人机交互层:它不暴露编辑器导航、transcript 回放、命令、模式、配置选择器、推理、计划、标题或工具展示。那些交互式渲染与人类提问属于 Web host 与 client 模块。仓库内的主要客户端是 subagent/subagent-acp(实现的是 subagent 提供方接口)。

插件接线

apply(ctx, config) 在 stdin/stdout 上打开一个 AgentSideConnection 并驱动 ctx.agentspackages/acp/acp/src/index.ts)。Stdout 保留给协议帧。

配置键默认含义
provider每个创建的 agent 的初始 provider 路由
model每个创建的 agent 的初始 model

两者在 schema 里皆可选Schema.object({ provider: Schema.string(), model: Schema.string() })),好让另一个 agent/请求监听器来提供目标;但构建可运行的 ACP 组合时两者都要填——examples/acp-demo 里二者即 required。

协议约定

方法行为
initialize协商支持版本,只广告 baseline 提示能力(image/audio/embedded-context 全为 false);不广告 session、editor、terminal、filesystem、MCP 能力
authenticate无操作(服务器不广告任何鉴权方法)
session/new用一个绝对主 cwd 创建全新 agent;空 additionalDirectories / mcpServers 接受,非空拒绝
session/prompt把文本块拼接起来、把 baseline 资源链接渲染成方括号文本引用、拒绝空/越 baseline 输入、每会话只允许一个在途请求,并等整台 agent 变 idle;正常静默报 end_turn,显式 ACP 取消/销毁/槽位接收被丢弃报 cancelled
session/cancel只取消被指名的那个 agent 并把其 pending prompt 结算为 cancelled;未知 id 是无操作
session/update对一条已提交的 assistant/message 里的每个非空文本块发一次 agent_message_chunk;原始 delta 与非消息事件省略
session/request_permission为携带工具调用 id 的桥接所有方审批请求提供一次性 allow/reject 选择;客户端可自动应答

一个连接可以拥有多个会话。桥接把所有记录按牌化的会话 id 区分,并在路由事件或权限请求前校验确切的 agent 身份。每个会话有独立的 prompt 槽、工作区、取消路径与 disposer。

initialize 的响应(src/index.ts):

return Promise.resolve({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})

session/prompt等整台 agent 变 idle,结算逻辑见 src/index.ts

void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return
record.inflight = undefined
const end = inflight.endReason
if (end === undefined) {
inflight.resolve('cancelled') // turnless slot
} else {
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
}
})

provider / model 路由

创建 agent 时,session/new 用插件配置构造"每个 agent 的路由选项"(仅填已配置字段,agentOptions()):

function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
return {
...config.provider !== undefined ? { provider: config.provider } : {},
...config.model !== undefined ? { model: config.model } : {},
}
}

agent 在宿主平面读取模型面对的行(无 preset 组合;配置了 roster 的部署需先自行 join 一个)。真正的 provider/model 适配器来自 ACP 组合所在的叶子(如 examples/acp-agent/cordis.yml 挂的 DeepSeek 适配器)——dsh-acp 只负责把 provider/model 传给 ctx.agents.create,路由选择由你的组合决定。

断开即 drain + dispose,不遗留孤儿

客户端断开与 Cordis dispose 共用同一个幂等 teardownquiesce()src/index.ts)。顺序:

  1. closed,拒绝新的 session 与 prompt;
  2. 结算所有 pending prompt(settle 为 cancelled),并取消桥接自有 agent 的顶层工作;
  3. drain 这些确切宿主名下可续的 continuable 后代(child-first,经 subagent 缝的 drainContinuableDescendants)——否则后代可能仍持有一个其 owner 已释放的运行时,而共享同一 Context 的其他前端仍在线;
  4. 并行 dispose 全部顶层 agent 句柄,await 每个结果后才报告任何失败。
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
await subagents.drainContinuableDescendants(records.map(record => record.agent))
}
const disposals = await Promise.allSettled(records.map(record => record.dispose()))

其他共享该 Context 的前端保留它们自己的 continuable 森林与准入。因此 ACP-only 插件重载不会留下孤儿 agent。连接级生命周期是"一个连接释放其全部会话";按会话的关闭未实现。

输出的取舍:只发已提交文本

ACPC 的 session/update 回路只发已提交的 assistant 文本:assistant/message 事件里每个非空文本块发一条 agent_message_chunk,图像以 [image attachment …] 文本占位呈现,推理/工具活动/计划/重试标记留在会话日志里通过其他接口观察(src/index.tsctx.on('session/event', …))。这是用令牌级延迟换干净自动化结果的有意取舍——未提交的 provider 块和重试尝试不可能泄漏部分文本。

turnEndToStopReasoncodec.ts)把回合结束映射为 ACP 的终止词表:completed → end_turnmax-tokens → max_tokensinterrupted → cancelled,其余普通静默 → end_turn。ACP 要求每个 prompt 响应都带 stopReason,但桥接不声称"某 prompt 专属回合结局";token 限制的回合结束结算为 end_turn,而关联回合的模型错误会立即拒绝该 prompt。

权限:策略化 allow / reject,无弹窗

审批走 approval/request 事件 → request_permission。桥接为携带 callId(工具调用 id)的请求提供一次性选项,从不断言来自未知客户端的响应是持久授权(src/index.ts):

return conn.requestPermission({
sessionId: record.agent.session.id,
toolCall: { toolCallId: request.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})

运行与可组合演示

仓库提供 examples/acp-demo 应用(dsh-acp-demo bin):默认 agent 脊柱 + 通过 @deepseek-ai/dsh-acp 创建客户端 agent + JSONL 持久化 + 语义 checkpoint,归拢在一个 newline JSON-RPC stdio bin 后面。它不装命令、用户交互、会话导航、配置选择器或 stdout logger。

pnpm --dir /path/to/deepseek-harness run demo:acp # 启动仓库的自动化服务器组合
dsh-acp-demo -c ./cordis.yml # 或显式加载你自己的组合

session/new 里的 cwd 必须是绝对路径;additionalDirectories 非空、mcpServers 非空都会拒绝——只支持一个工作区、baseline 提示、全新会话。资源链接会坍缩成文本引用而不是拉取内容。

验证 / 试一试

用任意 ACP 客户端往 dsh-acp-demo 的 stdin 打帧。下面是一段最小编排(initializesession/newsession/prompt → 收 update → 响应权限 → cancel):

# 1) 起服务器(诊断走 stderr,stdout 是 ACP wire)
pnpm --dir /path/to/deepseek-harness run demo:acp
# 2) 握手:stdout 只见一行行 JSON
--> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
<-- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":...,"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}}}} # 只广告 baseline
# 3) 建会话——不能同时给任何非空目录/MCP,且 cwd 必须绝对
printf '%s\n' \
'{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"'"$PWD"'","additionalDirectories":[],"mcpServers":[]}}'
# 回复里带 sessionId;随后 session/prompt 发文本、session/cancel 停它。

实测断言:

  • session/new非绝对 cwd → 报 invalidParams;非空附加目录 / MCP → invalidParams
  • 同一会话在途时再 session/prompt → 报 "a prompt is already in flight for this session"
  • 发图片/音频/嵌入块 → 报 "only text and resource_link prompt content is supported"
  • 连接直接断掉(关 stdin / Ctrl-D)→ 桥接把该连接旗下 agent 全部 drain + dispose 到静默,进程退出后无孤儿。

想把「子进程里跑 ACP」编成一个 subagent provider,见 子 Agent 与并行 里的 acp provider。

已知边界

  • 仅全新会话:load / list / resume / delete / fork 均不支持。
  • baseline 提示 + 单工作区:images/audio/embedded resources/非空附加目录/MCP server 拒绝;资源链接坍缩成文本引用。
  • 只发已提交答案:实时进度、推理、工具活动、计划、标题、用量不上 wire。
  • 连接拥有生命周期:一个连接释放其全部会话,无按会话关闭。
  • JSONL 持久化固定;兄弟插件可以污染 stdout(应用无法阻止另一条目写非协议字节)。

下一步

  • 想看 ACP 在子 agent 场景怎么当 client,见 子 Agent 与并行
  • 属性与限制完整引用见源码 packages/acp/acp/README{.zh}.mdpackages/examples/acp-demo/README{.zh}.md