ACP 自动化服务器
一句话版:
@deepseek-ai/dsh-acp在stdin/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.agents(packages/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 共用同一个幂等 teardown(quiesce(),src/index.ts)。顺序:
- 置
closed,拒绝新的 session 与 prompt; - 结算所有 pending prompt(settle 为
cancelled),并取消桥接自有 agent 的顶层工作; - 先 drain 这些确切宿主名下可续的 continuable 后代(child-first,经 subagent 缝的
drainContinuableDescendants)——否则后代可能仍持有一个其 owner 已释放的运行时,而共享同一 Context 的其他前端仍在线; - 再并行 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.ts 的 ctx.on('session/event', …))。这是用令牌级延迟换干净自动化结果的有意取舍——未提交的 provider 块和重试尝试不可能泄漏部分文本。
turnEndToStopReason(codec.ts)把回合结束映射为 ACP 的终止词表:completed → end_turn、max-tokens → max_tokens、interrupted → 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 打帧。下面是一段最小编排(initialize → session/new → session/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 与并行 里的
acpprovider。
已知边界
- 仅全新会话:load / list / resume / delete / fork 均不支持。
- baseline 提示 + 单工作区:images/audio/embedded resources/非空附加目录/MCP server 拒绝;资源链接坍缩成文本引用。
- 只发已提交答案:实时进度、推理、工具活动、计划、标题、用量不上 wire。
- 连接拥有生命周期:一个连接释放其全部会话,无按会话关闭。
- JSONL 持久化固定;兄弟插件可以污染 stdout(应用无法阻止另一条目写非协议字节)。
下一步
- 想看 ACP 在子 agent 场景怎么当 client,见 子 Agent 与并行。
- 属性与限制完整引用见源码
packages/acp/acp/README{.zh}.md与packages/examples/acp-demo/README{.zh}.md。