两个角色、双向请求与路由 enum
本章回答两个「为什么」:为什么 ACP 要分「两套方法」而不是一套?SDK 收到一坨 JSON 后,怎么知道该路由到哪个处理函数?
1. 双向,不是单向
传统「客户端→服务端」是单向的。ACP 不是——两边都能发起请求。这是它和很多协议的关键区别,也是「UX-first」的必然结果:agent 要改文件、跑命令、问用户授权,都得反过来求编辑器。文档直说:「ACP 用 JSON-RPC 的双向请求,让 agent 能向编辑器发请求,例如请求工具调用的权限」(architecture.mdx:24)。
于是方法天然分成两组,按「谁实现/谁处理」划分:
Client (编辑器实现这些) Agent (agent 实现这些)
──────────────────────── ────────────────────────
session/update (流式输出) initialize
session/request_permission (要授权) authenticate / logout
fs/read_text_file session/new · load · resume
fs/write_text_file session/list · delete · close
terminal/create · output · kill session/set_mode
terminal/release · wait_for_exit session/prompt
▲ ▲
└──── Agent 调这些(反向) ───────────────┘ Client 调这些(正向)
2. Agent 侧方法 —— 编辑器调,agent 实现
方法名是协议层稳定的常量(agent.rs:4881-4917),挑核心的:
| 线缆方法名 | 干什么 |
|---|---|
initialize | 握手协商 |
authenticate / logout | 登录 / 登出 |
session/new · session/load · session/resume | 建 / 重放加载 / 续上会话 |
session/list · session/delete · session/close | 列举 / 删除 / 关闭会话 |
session/set_mode | 切换 agent 模式(ask/architect/code…) |
session/prompt | 发起一轮对话(核心) |
session/cancel | 取消当前轮(通知) |
命名规约(
AGENTS.md:6-18):线缆名noun/verb时,Rust 侧用verb_noun。例如线缆session/new→ 结构体NewSessionRequest;线缆terminal/output(noun/noun)→TerminalOutputRequest。这解释了为什么类型名和线缆名看起来「调了个顺序」。
3. Client 侧方法 —— agent 反向调,编辑器实现
这些让 agent 能借编辑器的「手脚」干活(client.rs:2305-2322):
| 线缆方法名 | 干什么 |
|---|---|
session/update | agent 往 UI 流式推进度(通知,非请求) |
session/request_permission | 敏感操作前问用户要授权 |
fs/read_text_file · fs/write_text_file | 借编辑器读写文件(受 fs 能力位控制) |
terminal/create · output · release · wait_for_exit · kill | 借编辑器开/读/收终端(受 terminal 能力控制) |
为什么让 agent 通过编辑器读写文件,而不是自己 open?因为编辑器可能有未保存的缓冲区、有权限边界,且这样 UI 能「跟随」agent 的改动。ReadTextFileRequest 还支持 line/limit 分段读(client.rs:1002-1016),CreateTerminalRequest 支持 outputByteLimit 并规定「超限从头截断、且必须在字符边界截」(client.rs:1156-1163)——都是为真实编辑器体验设计的细节。
4. 核心机制:路由 enum 怎么把 JSON 分发对
它要解决的小问题。 进程从 stdin 收到一行 JSON,里面是个 {method, params}。SDK 怎么把它反序列化成对的 Rust 类型,再交给对的处理函数?
思路。 ACP 给每个「方向 × 种类」定义了一个聚合 enum,把该方向所有可能的消息列全。收到消息时,serde 按 untagged 一个个试着反序列化,命中即是。一共六个(三种 × 两方向):
| 方向 | Request | Response | Notification |
|---|---|---|---|
| Client→Agent | ClientRequest | AgentResponse | ClientNotification |
| Agent→Client | AgentRequest | ClientResponse | AgentNotification |
ClientRequest 就是「所有 client 能发给 agent 的请求」的全集(agent.rs:4930),每个变体的文档注释直接就是该方法的语义说明。它标了 #[serde(untagged)](agent.rs:4926)——序列化时不加外层 tag,反序列化时挨个试。
原理演示(示意,非源码): 把「分发」想成这样一段伪逻辑——
# 示意,非源码:SDK 收到一条 JSON-RPC 后大致怎么用聚合 enum 分发
def handle_incoming(raw): # raw 是收到的一行 JSON
msg = parse_json(raw)
if "id" in msg and "method" in msg: # 有 id + method = 请求
req = ClientRequest.deserialize(msg["params"], method=msg["method"])
result = dispatch_to_handler(req) # 按变体类型调对应处理器
send(Response.result(msg["id"], result))
elif "method" in msg: # 只有 method,没 id = 通知
note = ClientNotification.deserialize(msg["params"])
dispatch_notification(note) # 通知不回响应
# 重点看:enum 变体 → 处理函数 是一对一的,路由表就藏在 enum 定义里
真实实现。 每个聚合 enum 都配一个 method(),把变体映射回线缆方法名(agent.rs:5107-5142 ClientRequest::method),这就是「类型 ↔ 方法名」的权威对照表;反过来 SDK 也靠它给出去的消息填 method 字段。文档明说这些 enum「内部用于路由 RPC,你通常不用直接碰」(agent.rs:4920-4922)。
5. 关键细节:协议自身的方法不走能力门,但走 $/ 约定
绝大多数可选方法受能力位控制(见 04 章)。但有一类「协议级通知」是所有实现都该认的,它们以 $/ 打头(如 $/cancel_request)。约定是:收到 $/ 打头却处理不了的通知,可以直接忽略——比如单线程同步语言根本无法响应取消(protocol_level.rs:84-92)。这是从 LSP 借来的成熟约定。
6. 代码地图(本章)
| 主题 | 文件 | 符号 |
|---|---|---|
| Agent 方法名常量表 | …/src/v1/agent.rs | AGENT_METHOD_NAMES |
| Client 方法名常量表 | …/src/v1/client.rs | CLIENT_METHOD_NAMES |
| Client→Agent 请求全集 | …/src/v1/agent.rs | ClientRequest / ClientRequest::method |
| Agent→Client 响应全集 | …/src/v1/agent.rs | AgentResponse |
| Agent→Client 请求全集 | …/src/v1/client.rs | AgentRequest |
| Agent→Client 通知全集 | …/src/v1/client.rs | AgentNotification |
| Client→Agent 通知全集 | …/src/v1/agent.rs | ClientNotification(含 CancelNotification) |
| 反向文件读写 | …/src/v1/client.rs | ReadTextFileRequest / WriteTextFileRequest |
| 反向终端 | …/src/v1/client.rs | CreateTerminalRequest |
协议级 $/ 通知 | …/src/v1/protocol_level.rs | ProtocolLevelNotification |