跳到主要内容

Agent Client Protocol(ACP):把 agent 当子进程

这一章讲 CodeCompanion 的第二条接入路。前面四章的对象都是「发 HTTP 给一个模型 API」;ACP 完全不同——它把一个已经是 agent 的 CLI 程序(Claude Code、Codex、Gemini CLI…)拉起来当子进程,用 JSON-RPC 跟它对话。

1. 要解决的小问题:有些 agent 不是「模型」,是「程序」

Claude Code、Codex 这类工具本身就是完整的 agent——它们有自己的工具、自己的循环、自己的权限模型。你不会想用 HTTP 重新实现一遍它们的能力。

ACP(Agent Client Protocol) 是个标准协议(agentclientprotocol.com),让编辑器(client)和这些 agent 通过 stdin/stdout 上的 JSON-RPC 通信。CodeCompanion 当 client,把 agent 进程接进同一个聊天 buffer——对用户来说,和 Claude Code 对话的体验,和跟 Anthropic API 对话长得一样。

注:acp/init.lua 顶部注释致谢「Inspired by Zed's ACP implementation patterns」(acp/init.lua:11)。

2. 顶层:HTTP 路 vs ACP 路

01 章的 submit 末尾按 adapter.type 分流(chat/init.lua:1385):

Chat:submit

adapter.type?
├── "http" ──▶ _submit_http ──▶ http.lua Client ──▶ curl 发 REST 请求

└── "acp" ──▶ _submit_acp ──▶ acp/handler ──▶ JSON-RPC 写进子进程 stdin

session/update 通知 ◀──────┘ (流式回话、工具调用、权限请求)

两条路最终都把内容写回同一个聊天 buffer,共用 01 章的 buffer 逻辑。

3. 连接的生命周期

Connection(acp/init.lua:58)管一个 agent 子进程的整条命。它得先「握手」才能聊:

spawn 子进程(adapter.commands.default,如 "claude-agent-acp")


initialize ── 交换协议版本 + 能力(agentCapabilities)


authenticate ── 如 Claude Code 用 CLAUDE_CODE_OAUTH_TOKEN


session/new ── 拿到 session_id


现在可以 prompt 了(is_connected 全绿,acp/init.lua:107)

is_connected(acp/init.lua:107)要求四个条件同时满足:进程句柄在、已 initialize、已 authenticate、有 session_id。connect_and_authenticate(acp/init.lua:119)负责把这条链跑通。

Claude Code 的适配器(adapters/acp/claude_code.lua)就是一张配置表:启动命令(commands.default = { "claude-agent-acp" })、auth 方式(handlers.auth 读 OAuth token,:51)、client 能力(clientCapabilities.fs 声明能读写文件,:33)。还有一个 yolo 命令变体加 --yolo 跳过审批(:19)。

4. 发一轮 prompt:PromptBuilder 的流式回调

_submit_acp(chat/init.lua:1279)把活交给 ACP handler,底层是 PromptBuilder(acp/prompt_builder.lua)——一个流式 API,你挂一串回调,它把 agent 推来的各类 session/update 通知分发到对应回调(prompt_builder.lua:37):

回调agent 推来了什么
on_message_chunk一段回话文本
on_thought_chunk一段思考过程
on_tool_call / on_tool_updateagent 要调/更新一个工具的状态
on_permission_requestagent 请求权限(等价 HTTP 路的审批框)
on_write_text_fileagent 要写文件(client 代为执行)
on_planagent 给出的计划
on_complete / on_error / on_cancel收尾

这段演示「挂回调发 prompt」的用法(# 示意,非源码):

PromptBuilder.new(connection, messages)
:on_message_chunk(function(text) chat:add_buf_message(text) end) -- 文本上屏
:on_tool_call(function(call) render_tool(call) end) -- 渲染工具调用
:on_permission_request(function(req) ask_user(req) end) -- 弹审批
:on_complete(function() chat:ready_for_input() end)
:send()

和 HTTP 路最大的不同:工具执行发生在 agent 进程里,不在 CodeCompanion 里。CodeCompanion 只负责渲染工具调用、在 agent 请求权限时弹框、以及在 agent 要读写文件时代为操作(因为 client 声明了 fs 能力)。02/03 章那套工具系统在 ACP 路上不参与。

5. 底层:JSON-RPC over stdio

通信是行分隔的 JSON-RPC。Connection 维护一个 IdGenerator(给请求编号)和 LineBuffer(把 stdout 字节流切成完整 JSON 行),都来自 utils/jsonrpc(acp/init.lua:99)。请求-响应靠 pending_responses / _pending_callbacks 按 id 配对;agent 主动推的 session/update 是无 id 的通知,直接路由到当前 _active_prompt 的回调。

本章对 acp/init.lua 的握手与状态字段读得完整,但没有逐一追踪每个 JSON-RPC method 的收发实现(该文件约 1005 行,本文档读了前 120 行 + 关键符号)。完整 method 常量见 acp/methods.lua。(inferred:method 路由细节)

6. 巧妙之处

  • 同一个聊天 buffer 装两种后端:用户感知不到「这是 API 还是子进程 agent」,因为两条路都收口到 01 章的 buffer 渲染。
  • 能力协商:initialize 时交换 agentCapabilities,client 也声明自己能干什么(读写文件),双方据此决定谁干哪部分——比写死假设更稳。
  • 权限请求统一成「问用户」:不管是本地工具审批还是远端 agent 的 permission_request,最终都落到同一种「弹框问人」的交互。

7. 边界与局限

  • 依赖外部 CLI 已安装且在 PATH 里(如 claude-agent-acp);装不上就用不了。
  • agent 进程崩了或协议不匹配,连接整条失效,得重连。
  • 工具/编辑的真正执行在 agent 侧,CodeCompanion 的 7 级模糊匹配(03 章)在这条路上用不上——edit 由 agent 自己做。

8. 横向对比

HTTP 路是「我自己当 agent,模型只出脑子」;ACP 路是「别人已经是 agent,我只当它的 UI/宿主」。前者把 agent 智能握在自己手里(工具、edit、审批全自研),后者复用成熟 agent 的全部能力但让出控制权。CodeCompanion 两条都支持,是它区别于「纯 API 聊天插件」和「纯 agent CLI」的定位:一个既能自营、也能托管别家 agent 的编辑器宿主

9. 代码地图

主题文件符号
连接生命周期lua/codecompanion/acp/init.luaConnection.newConnection:connect_and_authenticateConnection:is_connected
流式 promptlua/codecompanion/acp/prompt_builder.luaPromptBuilder.newPromptBuilder:send、各 on_* 回调
JSON-RPC 方法lua/codecompanion/acp/methods.lua(method 常量表)
Claude Code 适配器lua/codecompanion/adapters/acp/claude_code.lua(整表)handlers.auth
聊天侧分流lua/codecompanion/interactions/chat/init.luaChat:_submit_acp