第2章:ACP 协议层 —— 平权架构与会话投影
本章聚焦 Zed Agent 架构中最关键的一道解耦线:Agent Client Protocol(ACP)。我们将剖析 AgentConnection 抽象接口及其 8 大可选能力扩展、UI 侧状态中枢 AcpThread 的六大条目与工具调用状态机,并深入原生 Agent(NativeAgentConnection)与外部独立 Agent 进程(AcpAgentServer)在这套协议下的同构平权设计。
一、ACP 协议是什么:为什么要专门立一个协议?
在 AI 编程工具生态中,协议的划分正在迅速专业化:
| 协议名称 | 核心发起方 | 核心连接对象 | 解决什么问题 |
|---|
| LSP (Language Server Protocol) | Microsoft | Editor ↔ Language Server | 语法高亮、自动补全、跳转定义、重命名(纯代码语义) |
| MCP (Model Context Protocol) | Anthropic | Agent ↔ Context / Tools Provider | 为模型提供外部文件、文档数据库与工具清单(模型的手脚) |
| ACP (Agent Client Protocol) | Zed Industries | Editor / Client ↔ Coding Agent | 会话管理、流式内容推送、Diff 交互审阅、权限审批弹窗与终端共享 |
很多初学者容易将 MCP 与 ACP 混淆:
- MCP 是由 Agent 驱动的:Agent 充当客户端,向外部 MCP 服务器索取只读资源或发起工具调用;
- ACP 则是面向人类交互宿主的:Zed 作为客户端宿主(Client),驱动背后的 Agent(Server)。Agent 通过 ACP 向 Zed 报告“我在想什么”、“我调用了什么工具”、“我希望修改哪份文件的哪些行(Diff)”、“请让用户批准这个命令(Permission)”。
官方在 Cargo.toml 中精确指定了依赖版本:
toml
# Cargo.toml:521
agent-client-protocol = { version = "=2.0.0", features = ["unstable"] }
引入 ACP 后,Zed 的 UI 交互层(crates/agent_ui)获得了一种极度纯粹的架构优势:它不需要了解模型是如何调用的,不需要了解原生 Thread 内部的双层循环,甚至不需要了解 Agent 到底是运行在本地内存还是外部独立进程。它唯一的依赖只有 acp_thread::AgentConnection 和 AcpThread。
二、核心接口:AgentConnection Trait 详解
整个 ACP 抽象层的代码集中在 crates/acp_thread/src/connection.rs。其中最高核心抽象是 AgentConnection:
rust
// crates/acp_thread/src/connection.rs:91-180
pub trait AgentConnection {
fn agent_id(&self) -> AgentId;
fn telemetry_id(&self) -> SharedString;
fn agent_version(&self) -> Option<SharedString> { None }
/// 创建一个全新的会话并返回 AcpThread 实体
fn new_session(
self: Rc<Self>,
project: Entity<Project>,
work_dirs: PathList,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>>;
fn supports_load_session(&self) -> bool { false }
fn load_session(
self: Rc<Self>,
session_id: acp::SessionId,
project: Entity<Project>,
work_dirs: PathList,
title: Option<SharedString>,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> {
Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported")))
}
fn auth_methods(&self) -> &[acp::AuthMethod];
fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
/// 向 Agent 发送提示词请求
fn prompt(&self, params: acp::PromptRequest, cx: &mut App)
-> Task<Result<acp::PromptResponse>>;
/// 取消当前正在进行的生成
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
// ... 8 个可选能力探测接口 ...
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
【来源:crates/acp_thread/src/connection.rs:91-230】
2.1 八大可选能力 Trait 与动态探测机制
在 Rust 这样强类型的静态语言中,如果把所有高级特性(如历史截断、动态切换模型、切换会话模式、读取配置选项)全部塞进 AgentConnection,会导致简单 Agent 的接入成本极高。
Zed 采用了一种优雅的 Capability Trait(能力特征)探测模式:AgentConnection 默认方法直接返回 None,具备相应特性的 Agent 则返回封装后的能力 Trait 实例:
rust
// 动态能力探测方法族 (crates/acp_thread/src/connection.rs:181-235)
impl dyn AgentConnection {
pub fn downcast<T: 'static + AgentConnection + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
self.into_any().downcast().ok()
}
}
这 8 大可选能力 trait 涵盖了智能体高级交互的方方面面:
| # | 可选能力 Trait | 方法签名与关键职责 | 原生 Agent 支持度 | 外部 Agent 支持度 |
|---|
| 1 | AgentSessionTruncate | fn run(&self, id: ClientUserMessageId, cx) -> Task<Result<()>><br>截断消息历史到指定节点 | ✅ 完整支持 | 依赖外部 Server 实现 |
| 2 | AgentSessionClientUserMessageIds | fn prompt(&self, client_id, params, cx)<br>允许客户端指定消息 ID,用于乐观更新去重 | ✅ 完整支持 | 视协议握手协商 |
| 3 | AgentSessionRetry | fn run(&self, cx) -> Task<Result<acp::PromptResponse>><br>重试最后一次失败或被阻断的请求 | ✅ 完整支持 | 依赖外部 Server 实现 |
| 4 | AgentSessionSetTitle | fn run(&self, title: SharedString, cx) -> Task<Result<()>><br>重命名会话标题 |
三、UI 侧的状态中枢:AcpThread 与六大条目
在 UI 层,每一个会话标签页对应一个 Entity<AcpThread>。AcpThread(位于 crates/acp_thread/src/acp_thread.rs,达 11,442 行)是前端对话流的唯一真实凭据。
3.1 核心状态容器:AgentThreadEntry
一个线程的时间线并不是扁平的字符串数组,而是一组强类型的结构化条目枚举:
rust
// crates/acp_thread/src/acp_thread.rs:396-404
pub enum AgentThreadEntry {
UserMessage(UserMessage),
AssistantMessage(AssistantMessage),
ToolCall(ToolCall),
Elicitation(ElicitationEntryId),
CompletedPlan(Vec<PlanEntry>),
ContextCompaction(ContextCompaction),
}
每一个条目都有着极其深刻的工程用意:
- UserMessage:用户输入的指令,包含文本块、引用文件(@mention)与图片数据;
- AssistantMessage:模型的文本输出块(AgentText)或思考过程块(AgentThinking);
- ToolCall:模型发起的一次工具调用,维护着从审批、执行到完成的完整生命周期;
- Elicitation:模型主动向用户提问、索取信息的交互弹窗条目;
- CompletedPlan:模型输出的结构化任务计划清单(Plan Mode);
- ContextCompaction:上下文发生折叠截断时的分割线标记,记录压缩前后的 Token 变化。
3.2 工具调用状态机:ToolCallStatus
工具调用是 Agent 行动力的源泉。在 AcpThread 内部,工具调用被塑造成一个显式的 7 状态机:
rust
// crates/acp_thread/src/acp_thread.rs:1266-1284
pub enum ToolCallStatus {
/// 工具调用已被提出,准备展示给用户但尚未运行
Pending,
/// 触发了权限控制,正在等待用户在 UI 弹窗中确认
WaitingForConfirmation {
current_status: acp::ToolCallStatus,
options: PermissionOptions,
respond_tx: oneshot::Sender<RequestPermissionOutcome>,
kind: AuthorizationKind,
},
/// 用户已授权(或自动放行),工具正在执行
InProgress,
/// 工具执行成功并返回结果
Completed,
/// 工具执行抛出错误
Failed,
/// 用户主动拒绝授权
Rejected,
/// 用户在中途取消了生成
Canceled,
}
【来源:crates/acp_thread/src/acp_thread.rs:1266-1284】
状态转换如下图所示:

ToolCallStatus 的状态转换。Pending 要么被自动放行进 InProgress,要么落进 WaitingForConfirmation 等人;等待态有三个出口——用户批准(回到 InProgress)、拒绝、以及审批期间被打断。四个终态都会终结这次工具调用。
3.3 为什么 Diff 和 Terminal 必须是协议的一级公民?
大部分 Web 框架或简易 Harness 习惯于把工具的返回结果统一当成一段 JSON 或普通 Markdown 字符串塞回对话流中。但在桌面级代码编辑器中,这种做法会导致灾难性的用户体验 —— 用户不仅无法清晰看懂修改,而且无法点击、无法局部复原。
Zed 在 ToolCallContent 中做出了革命性的设计:Diff 与 Terminal 不是普通的文本输出,而是强类型的协议级实体(First-Class Entities):
rust
// crates/acp_thread/src/acp_thread.rs:1810-1815
pub enum ToolCallContent {
ContentBlock(ContentBlock),
Diff(Entity<Diff>),
Terminal(Entity<Terminal>),
}
- Diff(Entity<Diff>):当模型执行 edit_file 或外部 Agent 报告文件差异时,ACP 协议直接传递 acp::ToolCallContent::Diff。Zed 前端直接在此处嵌入一个实时的、支持语法高亮与行号对齐的真编辑器 Diff 控件。用户可以在此处直接看到 Myers 字符级增删,甚至逐行点击接受/拒绝!
- Terminal(Entity<Terminal>):当模型调用 terminal 运行长耗时构建或测试命令时,Zed 不仅仅是将 stdout 打印为一段日志,而是直接绑定一个真正的伪终端(PTY)实体。用户不仅能看到流式输出,还能像在普通终端窗口中一样进行 Ctrl+C 打断或动态交互。
四、事件消化流:handle_session_update 剖析
当底层 Agent 在运转时,它是如何将海量的流式信息更新到 AcpThread 的?答案在 crates/acp_thread/src/acp_thread.rs:2593 的 handle_session_update 函数中:
rust
// crates/acp_thread/src/acp_thread.rs:2593-2720 核心逻辑节选
pub fn handle_session_update(
&mut self,
update: acp::SessionUpdate,
cx: &mut Context<Self>,
) -> Result<(), acp::Error> {
match update {
acp::SessionUpdate::UserMessageChunk(acp::ContentChunk { content, message_id, .. }) => {
// 乐观更新去重逻辑:如果本地已经乐观插入了该 UserMessage,跳过重复回显
let already_in_user_message = self.check_and_merge_optimistic(message_id, &content);
if !already_in_user_message {
self.push_user_content_block_from_agent(message_id, content, cx);
}
}
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, message_id, .. }) => {
self.push_assistant_content_block_with_message_id(message_id, content, false, false, cx);
}
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { content, message_id, .. }) => {
// 模型的思考链过程(如 Claude 3.7 Thinking / DeepSeek R1 Reasoning)
self.push_assistant_content_block_with_message_id(message_id, content, true, false, cx);
}
acp::SessionUpdate::ToolCall(tool_call) => {
self.upsert_tool_call(tool_call, cx)?;
}
acp::SessionUpdate::ToolCallUpdate(tool_call_update) => {
self.update_tool_call(tool_call_update, cx)?;
}
acp::SessionUpdate::Plan(plan) => {
self.update_plan(plan, cx);
}
acp::SessionUpdate::SessionInfoUpdate(info_update) => {
if let MaybeUndefined::Value(title) = info_update.title {
self.title = Some(title.into());
cx.emit(AcpThreadEvent::TitleUpdated);
}
}
acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { current_mode_id, .. }) => {
cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id));
}
acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate { config_options, .. }) => {
cx.emit(AcpThreadEvent::ConfigOptionsUpdated(config_options));
}
acp::SessionUpdate::UsageUpdate(update) => {
let usage = self.token_usage.get_or_insert_with(Default::default);
usage.max_tokens = update.size;
usage.used_tokens = update.used;
cx.emit(AcpThreadEvent::TokenUsageUpdated);
}
_ => {}
}
Ok(())
}
【来源:crates/acp_thread/src/acp_thread.rs:2593-2720】
乐观更新与回显合并(Optimistic Merging)
注意代码中针对 UserMessageChunk 的精妙处理:
当用户在输入框敲下回车时,UI 层为了极致的响应速度,会**立即(乐观地)**在本地 AcpThread 的末尾追加一条 AgentThreadEntry::UserMessage 并开始渲染。
但随后发往 ACP 协议的请求中,某些外部 Server(例如特定版本的代理或 CLI)在处理时会把用户刚发的内容原样通过 UserMessageChunk 事件回显回来。
如果没有去重防御,用户的同一条提示词就会在屏幕上重复渲染两次。AcpThread 在这里维护了 is_optimistic 标记,对所有匹配的内容块执行局部合并,只认领 protocol_id,从而保证界面既有零延迟的打字反馈,又绝对不会发生内容闪烁或条目重叠。
五、平权实现:原生 NativeAgent vs 外部 AcpAgentServer
ACP 架构最震撼的魅力,在于其如何将两个世界完美桥接:

同一条 UI 指令的两条路径:原生走 AcpThread → NativeAgentConnection → Thread,外部走 AcpThread → AcpAgentServer → 子进程 stdio JSON-RPC。两条路径都在 AcpThread 之上汇合,触发的是同一套 UI 渲染管线——这就是「平权」的确切含义。
5.1 原生侧适配器:NativeAgentConnection
在 crates/agent/src/agent.rs:2276 中,原生状态机产生的 ThreadEvent 是通过一个长生命周期的异步任务翻译成 AcpThread 更新的:
rust
// crates/agent/src/agent.rs:2276-2315
fn handle_thread_events(
mut events: mpsc::UnboundedReceiver<Result<ThreadEvent>>,
acp_thread: WeakEntity<AcpThread>,
connection: Option<NativeAgentConnection>,
cx: &App,
) -> Task<Result<acp::PromptResponse>> {
cx.spawn(async move |cx| {
while let Some(result) = events.next().await {
match result? {
ThreadEvent::UserMessage(message) => {
acp_thread.update(cx, |t, cx| {
for content in &*message.content {
t.push_user_content_block(Some(message.id.clone()), content.clone().into(), cx);
}
})?;
}
ThreadEvent::AgentText(text) => {
acp_thread.update(cx, |t, cx| t.push_assistant_content_block(text.into(), false, cx))?;
}
ThreadEvent::AgentThinking(text) => {
acp_thread.update(cx, |t, cx| t.push_assistant_content_block(text.into(), true, cx))?;
}
ThreadEvent::ToolCallAuthorization(auth) => {
// 原生 Agent 发起权限申请,交由 AcpThread 弹窗
let outcome_task = acp_thread.update(cx, |t, cx| {
t.request_tool_call_authorization(auth.tool_call, auth.options, auth.kind, cx)
})??;
// ... 异步等待用户选择并回传给 Thread ...
}
// ... 其他事件映射 ...
}
}
Ok(acp::PromptResponse::default())
})
}
【来源:crates/agent/src/agent.rs:2276-2325】
5.2 外部侧驱动器:AcpAgentServer
外部 Agent 的驱动实现在 crates/agent_servers/src/acp.rs(5,069 行)。它接管了外部进程的整个生命周期:
- 进程孵化与管道捕获:通过 Tokio / 标准库启动子进程,将 stdin/stdout 重定向为管道;
- 协议握手:发送 initialize 请求,协商 ProtocolVersion,交换双方支持的能力集;
- 认证管道映射:如果外部 Agent(如 Gemini CLI)需要 OAuth 登录,AcpAgentServer 会捕获认证事件并在 Zed 内部弹出一个专用终端运行认证命令(GEMINI_TERMINAL_AUTH_METHOD_ID = "spawn-gemini-cli");
- 全向调试记录器:为了便于开发者排错,内置了最大 2,000 条消息的环形缓冲区(MAX_DEBUG_BACKLOG_MESSAGES = 2000),忠实记录 Incoming、Outgoing、Stderr 三向原始数据。
六、权限审批回传通道:request_permission 的闭环链路
当一个工具(无论是本地还是外部的)需要执行危险操作时,权限裁决是如何穿透协议完成人机确认的?

权限裁决穿透协议的完整闭环:工具触发安全规则后,AcpThread 把状态置为 WaitingForConfirmation 并渲染授权卡片,用户的选择经 oneshot::Sender<RequestPermissionOutcome> 原路回传,工具才继续执行。
注意这个设计中极其精妙的打断保护:
如果在授权弹窗还悬挂在用户屏幕上时,用户突然在输入框中输入了一条新的提示词(Follow-up),或者点击了停止按钮(Cancel),系统如何应对?
在 crates/agent/src/agent.rs:2325 中明确记录了:
rust
acp_thread::RequestPermissionOutcome::InterruptedByFollowUp => {
acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new(FOLLOW_UP_PERMISSION_DENIED_OPTION_ID),
acp::PermissionOptionKind::RejectOnce,
)
}
——任何后续输入都会导致当前的权限请求以 RejectOnce 自动快速失败,而绝不会导致进程永远挂起或状态卡死!
七、动手复核
你可以在本地源码检出中执行以下命令,验证本章引用的核心数据和架构事实:
bash
# 1. 查看 AgentConnection trait 的所有核心方法定义
sed -n '91,150p' crates/acp_thread/src/connection.rs
# 2. 查看 AcpThread 的 6 大条目全集
sed -n '396,410p' crates/acp_thread/src/acp_thread.rs
# 3. 统计 handle_session_update 覆盖的 ACP 更新事件类型数
grep -n "acp::SessionUpdate::" crates/acp_thread/src/acp_thread.rs
# 4. 查看 NativeAgentConnection 对 AgentConnection 的具体实现
sed -n '2726,2780p' crates/agent/src/agent.rs
# 5. 查看外部 Agent 驱动中的调试缓冲区大小定义(实测 2000)
grep -n "MAX_DEBUG_BACKLOG_MESSAGES" crates/agent_servers/src/acp.rs
八、总结
Zed 的 ACP 协议层展现了桌面级 IDE 架构的非凡远见:
- 拒绝特权:它没有为自己的原生模型开辟捷径,使得整个 UI 和交互体系天然具备接入任何未知第三方 Agent 的通用性;
- 强类型投影:它将模型生成过程抽象为 AgentThreadEntry、7 状态工具机与第一等公民的 Diff / Terminal,保证了桌面级交互的极致流畅与可控性;
- 异步解耦:通过清晰的通道与回调机制,让模型生成的快慢、工具执行的耗时与 UI 界面的帧率彻底脱钩。
在下一章中,我们将离开协议接入层,向下潜入 Zed 原生 Agent 的中枢神经 —— Thread 核心状态机与双层执行循环。