RFC-0027: ACP Subagent Zed 兼容性¶
概述¶
本 RFC 提出 AgentPool ACP Server 与 Zed 编辑器 subagent UI 的兼容性改造方案。当前 AgentPool 的 ACP Server 虽然已实现三种 subagent 展示模式(legacy/inline/tool_box),但所有模式均未填充 ACP 协议中的 _meta 扩展字段,导致 Zed 编辑器无法检测和渲染 subagent 面板 UI。Zed 的 subagent 功能已正式 GA(2026-02-27,PR #50493),其 subagent UI 完全依赖于 ToolCallStart/ToolCallProgress 事件中的 _meta.subagent_session_info 字段。
本 RFC 识别了 5 个关键差距(GAP),其中 GAP 1 为 P0 阻塞性问题,并提出 3 个方案选项,通过 4 阶段实施计划(Phase 0-3)逐步实现完整的 Zed subagent 兼容性。
预期结果:Zed 编辑器能够正确检测 AgentPool 的 subagent 工具调用,渲染展开/折叠卡片 UI,并独立管理子会话的生命周期。
背景与上下文¶
当前系统状态¶
AgentPool ACP Server 实现了完整的 ACP v1 协议,包括 initialize、session/new、session/prompt、session/resume、session/fork、session/close、session/list 等方法。对于 subagent 场景,Server 提供了三种展示模式:
| 模式 | 描述 | 当前行为 |
|---|---|---|
| legacy(默认) | 扁平化文本 | 将 subagent 文本内容以 markdown headers 嵌入 AgentMessageChunk |
| inline | 独立 ToolCall | 每个 subagent 活动创建独立的 ToolCallStart/ToolCallProgress,但全部在同一 session |
| tool_box | 工具箱累积 | 单个 ToolCallStart per subagent 调用,内容累积显示 |
| zed 📝 | Zed 适配(提议) | ToolCallStart 含 _meta(subagent_session_info + tool_name),Phase 2 增加子 ACP session |
⚠️ 重要变更(Phase 1):当
display_mode == "zed"时,SpawnSessionStart处理产出带_meta(subagent_session_info + tool_name)的ToolCallStart。inline/tool_box/legacy模式下 SpawnSessionStart 行为不变(AgentMessageChunk,无 _meta)。子级 ToolCallStart/ToolCallProgress 在所有模式下均不携带_meta。重要变更(Phase 2):当
display_mode == "zed"时,SubAgentEvent 事件路由到子 ACP session。其他 display_mode 下行为不变。
相关工作¶
| RFC | 状态 | 与本 RFC 的关系 |
|---|---|---|
| RFC-0013 | ✅ 已实现 | 统一了 OpenCode Server 的 main+subagent 事件处理 EventProcessor |
| RFC-0014 | ✅ 已实现 | 添加了 SpawnSessionStart 事件,提供显式子会话创建信号 |
| RFC-0025 | 📝 草稿 | Shared Agent Architecture,单共享 Agent + per-session 状态 |
| RFC-0026 | ✅ 已实现 | Per-Session Agent Isolation,会话级 Agent 隔离 |
现有基础设施¶
AgentPool 核心层已具备子会话管理基础,Phase 2 应桥接而非重建:
| 组件 | 位置 | 现有能力 |
|---|---|---|
SessionManager.create_child_session() |
sessions/manager.py |
创建子会话并关联 parent_id |
SessionManager.get_child_sessions() |
sessions/manager.py |
查询父会话的所有子会话 |
SessionData.parent_id |
sessions/models.py |
会话的父会话 ID 字段 |
Phase 2 的 ACPSessionManager 应委托核心 SessionManager.create_child_session() 管理子会话生命周期,而非在 ACP 层重新实现。
术语表¶
| 术语 | 定义 |
|---|---|
| ACP | Agent Client Protocol,编辑器与 AI Agent 之间的标准化通信协议 |
_meta |
ACP 协议中所有类型包含的扩展字段,类型为 { [key: string]: unknown } |
| SubagentSessionInfo | Zed 定义的 _meta 扩展结构,包含 session_id、message_start_index、message_end_index |
| ToolCallStart | ACP 事件类型,通知客户端新的工具调用已启动(Zed 中称为 tool_call) |
| ToolCallProgress | ACP 事件类型,工具调用的状态或结果更新(Zed 中称为 tool_call_update) |
| AnnotatedObject | ACP schema 基类,具有 field_meta 字段(序列化为 _meta) |
| display_mode | AgentPool 的 subagent 展示模式配置,支持 legacy/inline/tool_box/zed |
| zed (display_mode) | Zed 编辑器专属 subagent 适配模式,通过 _meta.subagent_session_info 扩展实现子会话 UI |
Zed Subagent 实现方式¶
Zed 的 subagent 不是 ACP 协议的标准特性,而是通过 _meta 扩展机制实现:
// Zed: crates/acp_thread/src/acp_thread.rs:69-70
pub const SUBAGENT_SESSION_INFO_META_KEY: &str = "subagent_session_info";
pub const TOOL_NAME_META_KEY: &str = "tool_name";
// Zed: crates/agent/src/tools/spawn_agent_tool.rs:155-168
// spawn 时:创建 SubagentSessionInfo 并写入 _meta
let session_info = SubagentSessionInfo {
session_id: subagent.id(),
message_start_index: subagent.num_entries(cx), // 当前条目数(非硬编码 0)
message_end_index: None, // spawn 时未知,后续更新
};
event_stream.update_fields_with_meta(
acp::ToolCallUpdateFields::new(),
Some(acp::Meta::from_iter([(
SUBAGENT_SESSION_INFO_META_KEY.into(),
serde_json::json!(&session_info), // ← JSON Object, NOT string
)])),
);
// 完成时:更新 message_end_index
session_info.message_end_index =
cx.update(|cx| Some(subagent.num_entries(cx).saturating_sub(1)));
// ⚠️ saturating_sub(1):0-based index,非 count
Zed 客户端通过 subagent_session_info_from_meta() 函数从 _meta 中提取 SubagentSessionInfo,进而调用 tool_call_for_subagent(session_id) 定位父级 ToolCall 以渲染展开/折叠卡片 UI。
问题陈述¶
GAP 1(P0 阻塞):_meta 字段从未填充¶
Zed 期望:每个 ToolCallStart 和 ToolCallProgress(Zed 中分别称为 tool_call 和 tool_call_update)均可携带 _meta 字段。对于 subagent 工具调用,Zed 读取:
- subagent_session_info:{session_id, message_start_index, message_end_index} — 作为 JSON 对象序列化在 _meta 内
- tool_name:底层工具名称(用于显示归因,Zed 使用 snake_case 的 TOOL_NAME_META_KEY = "tool_name")
AgentPool 现状:AnnotatedObject(ToolCallStart/ToolCallProgress 的基类)具有 field_meta 字段(序列化为 _meta),但 从未在任何位置填充。在 event_converter.py 的第 449、490、506、552、584、755、781、815、991 行,所有 ToolCallStart() 和 ToolCallProgress() 构造函数均省略了 field_meta 参数。
⚠️ 注意:上述行号中,部分对应非 subagent 的 ToolCall(如 BuiltinToolCallPart)。Phase 1 修改时,仅 subagent 相关的 ToolCallStart/ToolCallProgress 需传入包含
subagent_session_info的field_meta;非 subagent ToolCall 可选传入仅含tool_name的field_meta(用于 Zed 工具归因显示),**不得**传入subagent_session_info。
影响:Zed 完全无法检测 subagent 工具调用。没有 _meta.subagent_session_info,Zed 的 subagent_session_info_from_meta() 返回 None,tool_call_for_subagent() 永远找不到父级 ToolCall。整个 subagent 面板 UI 无法运作。
代码证据:
# event_converter.py:449 — BuiltinToolCallPart ToolCallStart
yield ToolCallStart(
tool_call_id=tool_call_id,
title=state.title,
kind=state.kind,
raw_input=state.raw_input,
status="pending",
# ❌ field_meta 未传入
)
# event_converter.py:755 — inline 模式 subagent text ToolCallStart
yield ToolCallStart(
tool_call_id=state.text_output_call_id,
title=f"[`{source_name}`] Output",
kind="other",
status="pending",
content=[ContentToolCallContent.text(text=full_content)]
if full_content
else None,
# ❌ field_meta 未传入
)
GAP 2(P1):Subagent 事件扁平化到父流¶
Zed 期望:Subagent 在**独立的 ACP session**(通过 session/new 创建)中运行,父级 ToolCall 的 _meta.subagent_session_info.session_id 链接到该子会话。Zed 的 AcpThread::tool_call_for_subagent(session_id) 定位父级 ToolCall 以渲染展开/折叠卡片 UI。
AgentPool 现状:三种展示模式均**未创建独立的 ACP session**:
- legacy(服务器默认):将 subagent 文本扁平化到 AgentMessageChunk 中,使用 markdown headers 分隔
- inline:为每个 subagent 活动创建独立的 ToolCallStart/ToolCallProgress,但全部在**同一 session**
- tool_box:在同一 session 中为每个 subagent 调用创建一个 ToolCallStart,累积内容
三种模式均不:(1) 调用 session/new 创建子 ACP session,(2) 通过 ACP 发射 SubagentSpawned 事件,或 (3) 使 subagent 内容作为独立 session 可访问。
GAP 3(P1):SpawnSessionStart 事件未用于 ACP subagent 信令¶
AgentPool 现状:SpawnSessionStart 事件(定义于 events.py:646)由 subagent_tools.py(第 109 行)发射。ACP converter 在 event_converter.py:686-693 处理该事件,仅发射一个带有 emoji 前缀的简单 AgentMessageChunk.text()。
SpawnSessionStart 事件携带 child_session_id、parent_session_id 和 tool_call_id — 恰好是构建 SubagentSessionInfo 所需的信息。但 ACP converter 未:(1) 创建子 ACP session,(2) 在父级 ToolCallStart 的 _meta 中发射 subagent_session_info,或 (3) 将 subagent 事件路由到子会话。
GAP 4(P2):无 message_start_index / message_end_index 追踪¶
Zed 期望:SubagentSessionInfo 包含 message_start_index 和 message_end_index(usize 类型),定义子会话线程中的条目范围,用于确定哪些条目属于特定的 subagent turn。
AgentPool 现状:无 message index 追踪概念。
GAP 5(P2):_meta 中未发射 tool_name 用于工具归因¶
Zed 源码 acp_thread.rs:64-67:
pub fn meta_with_tool_name(tool_name: &str) -> acp::Meta {
acp::Meta::from_iter([(TOOL_NAME_META_KEY.into(), tool_name.into())])
}
Zed 从 _meta 读取 tool_name key(TOOL_NAME_META_KEY = "tool_name",snake_case)以显示产生特定 ToolCall 的工具名称。AgentPool 从未设置此字段。
影响分析¶
若不解决上述差距,以下功能将持续不可用:
| 功能 | 影响 | 严重程度 |
|---|---|---|
| Zed subagent 面板 UI | 完全不可用 | P0 |
| Subagent 展开/折叠卡片 | 完全不可用 | P0 |
| 子会话独立管理 | 完全不可用 | P1 |
| Subagent 工具归因显示 | 信息缺失 | P2 |
| Message 范围追踪 | 信息缺失 | P2 |
目标与非目标¶
目标¶
| ID | 目标 | 优先级 |
|---|---|---|
| G1 | ToolCallStart/ToolCallProgress 必须在 subagent 场景下填充 _meta.subagent_session_info |
P0 |
| G2 | ToolCallStart/ToolCallProgress 必须在 subagent 场景下填充 _meta.tool_name |
P0 |
| G3 | Subagent 运行应在独立的 ACP session 中执行,事件路由到子会话 | P1 |
| G4 | SubagentSessionInfo 应包含 message_start_index/message_end_index |
P2 |
| G5 | 提供 zed 显示模式,用户显式配置激活 Zed 适配 |
P2 |
| G6 | 保持与现有 legacy/inline/tool_box 模式的向后兼容 | P0 |
非目标¶
| ID | 非目标 | 理由 |
|---|---|---|
| NG1 | 修改 ACP 协议 schema | _meta 是协议扩展机制,不需要修改 schema |
| NG2 | 支持 Zed Parallel Agents | Parallel Agents 是用户级并行架构,非 subagent 嵌套,不在本 RFC 范围 |
| NG3 | 实现 ACP Proxy Chains | Proxy Chains RFD 仍在草案阶段,属于长期方向 |
| NG4 | 修改 SpawnSessionStart 或 SubAgentEvent 数据结构 |
保持事件模型稳定 |
| NG5 | 支持 MAX_SUBAGENT_DEPTH > 1 | Zed 限制为 1 层,无需支持更深层嵌套 |
评估标准¶
| 标准 | 权重 | 描述 | 最低阈值 |
|---|---|---|---|
| Zed 兼容性 | 关键 | Zed 能正确渲染 subagent 卡片 UI | SubagentSessionInfo 被 Zed 正确解析 |
| 向后兼容性 | 关键 | 现有客户端(非 Zed)行为不变 | 现有三种模式功能正常 |
| 非Zed零影响 | 关键 | zed 模式下的 _meta 填充不影响其他客户端行为 |
legacy/inline/tool_box 模式行为完全不变 |
| 代码侵入性 | 高 | 对现有 event_converter.py 的修改范围 | 不超过现有代码量的 30% |
| 实施复杂度 | 中 | 开发工时和代码行数估算 | Phase 1 不超过 150 行新增 |
| 可维护性 | 中 | 新增代码的清晰度和可测试性 | 单元测试覆盖率 ≥ 80% |
| 协议合规性 | 中 | 符合 ACP v1 扩展机制规范 | 仅使用 _meta 扩展,不引入非标准字段 |
方案分析¶
选项 1:最小化修复 — 仅填充 _meta(Phase 1)¶
描述:仅实施 Phase 1,在现有 ToolCallStart/ToolCallProgress 构造时填充 field_meta,包含 SubagentSessionInfo 和 tool_name。不创建独立子会话,不路由事件,不新增显示模式。
实施范围:
- 在 event_converter.py 中创建 _build_subagent_meta() 辅助方法
- 修改所有 subagent 相关的 ToolCallStart/ToolCallProgress 构造调用,传入 field_meta
- SpawnSessionStart 事件处理改为发射带 _meta 的 ToolCallStart,而非纯文本
优势:
- 修改范围最小,仅涉及 event_converter.py 一个文件
- 风险最低,不改变事件路由逻辑
- Zed 可立即检测到 subagent 存在(虽然内容仍在同一 session)
- 开发工时最短
劣势:
- Zed 检测到 subagent_session_info.session_id 后会尝试加载该 session,但 session 不存在
- 不满足 Zed 的完整子会话模型,展开/折叠 UI 可能显示为空或错误
- message_start_index/message_end_index 无法提供有效值
- 仅是临时方案,后续仍需实施 Phase 2
评估:
| 标准 | 评分 | 说明 |
|---|---|---|
| Zed 兼容性 | ⅖ | Zed 可检测 subagent,但子会话内容为空 |
| 向后兼容性 | 5/5 | 不影响现有模式 |
| 代码侵入性 | 5/5 | 仅新增 ~130 行 |
| 实施复杂度 | 5/5 | 1-2 天 |
| 可维护性 | ⅗ | 临时方案,需后续迭代 |
| 协议合规性 | 5/5 | 仅使用 _meta 扩展 |
工作量估算:低(~130 行新增,1-2 天)
风险评估:
- 技术风险:低。Zed 可能对不存在的 session_id 显示错误,但不影响父会话
- 兼容性风险:低。_meta 是可选字段,非 Zed 客户端会忽略
选项 2:完整 ACP 子会话支持 — _meta + 子会话创建 + 事件路由 + display_mode=zed(Phase 0+1+2+3)¶
描述:在 Phase 1 基础上,增加子会话创建(Phase 2)和 message index 追踪(Phase 3)。当 SpawnSessionStart 事件到达时,创建独立的子 ACP session;SubAgentEvent 中的内容事件路由到子会话;追踪 message 范围索引。通过 display_mode=zed 配置激活 Zed 适配,无需自动检测客户端类型。
实施范围:
- Phase 1:当 display_mode == "zed" 时填充 _meta(SubagentSessionInfo + tool_name);其他 display_mode 行为不变
- Phase 2:子会话创建与事件路由(仅 display_mode == "zed")
- 修改 SpawnSessionStart 处理:调用 session_manager 创建子 ACP session
- 修改 SubAgentEvent 处理:将 inner_event 路由到子会话的 session update 流
- 子会话生命周期管理:spawn 时创建,StreamCompleteEvent 时关闭
- Phase 3:message_start_index / message_end_index 追踪
- 在子会话中维护消息计数器
- 在 ToolCallProgress 的 _meta 中更新 message_end_index
优势:
- 完全符合 Zed 的 subagent 数据模型
- 子会话内容独立可访问,Zed 可正确渲染展开/折叠 UI
- message_start_index/message_end_index 使 Zed 可精确定位条目范围
- 单一 display_mode=zed 配置,无需额外的路由层或自动检测
- 非 Zed 客户端零影响:legacy/inline/tool_box 模式行为完全不变
劣势:
- 修改范围较大,涉及 event_converter.py、session_manager.py、session.py
- 子会话生命周期管理增加复杂度(创建、路由、关闭、异常处理)
- 子会话的 session update 需要通过父会话的 JSON-RPC 连接发射,需确认 ACP 传输层支持
- 需要处理嵌套 subagent(虽然 Zed 限制 depth=1,但内部可能有多层)
- 增加内存占用(每个子会话需要独立的 session 状态)
评估:
| 标准 | 评分 | 说明 |
|---|---|---|
| Zed 兼容性 | 5/5 | 完整支持 Zed subagent UI |
| 向后兼容性 | 5/5 | display_mode=zed 为显式配置,非 Zed 客户端零影响 |
| 代码侵入性 | ⅗ | 涉及 3 个文件,~380 行新增 |
| 实施复杂度 | ⅖ | 5-7 天,需处理 session 生命周期 |
| 可维护性 | ⅘ | 架构清晰,但子会话管理增加维护成本 |
| 协议合规性 | 5/5 | 完全使用 ACP 标准机制 |
工作量估算:中高(~380 行新增,5-7 天)
风险评估: - 技术风险:中。子会话的 session update 发射路径需验证;ACP 传输层是否支持从非活跃 session 发射 update 需确认 - 兼容性风险:低。子会话路由为新增逻辑,现有模式不受影响 - 性能风险:中。每个子会话增加内存和事件处理开销
选项 3:新增 zed_subagent 显示模式 — 全新 display mode 替代现有三种(Phase 0+1+2+3+4)¶
描述:在选项 2 基础上,新增 zed_subagent 显示模式(Phase 4),专为 Zed 客户端优化。同时实现客户端自动检测:当 ACP initialize 握手时检测客户端标识,自动选择 zed_subagent 模式。
⚠️ 注意:选项 2 现已通过
display_mode=zed实现了 Zed 专用显示模式。选项 3 提议的zed_subagent枚举值与选项 2 的display_mode=zed功能等价,且选项 3 的客户端自动检测已被明确排除(subagent 适配是 Zed 专属 ad-hoc 扩展,应作为显式配置项)。因此选项 3 相对于选项 2 无增量价值,已被选项 2 的display_mode=zed方案取代。
实施范围:
- Phase 1-3:同选项 2
- Phase 4:zed_subagent 显示模式 + 客户端自动检测
- 新增 zed_subagent display_mode 枚举值
- 实现 _convert_subagent_zed() 方法,完全按照 Zed 期望的事件序列输出
- 在 initialize 响应中读取客户端信息,自动设置 display_mode
- 允许 YAML 配置覆盖自动检测
优势: - 专门的 Zed 优化路径,不受现有模式约束 - 自动检测减少用户配置负担 - 完整的 Zed subagent 体验:独立子会话 + 展开/折叠 + 工具归因 + message 范围 - 现有模式完全不受影响,零风险 - 未来可扩展:其他 IDE(如 JetBrains)可能有不同的 subagent 期望,可新增专用模式
劣势: - 新增第 4 种 display_mode,增加维护面积 - 客户端自动检测可能不准确(依赖客户端标识字段,部分客户端可能不提供) - 最长开发周期 - 自动检测逻辑可能在 Zed 版本更新后失效
评估:
| 标准 | 评分 | 说明 |
|---|---|---|
| Zed 兼容性 | 5/5 | 完全优化,最佳 Zed 体验 |
| 向后兼容性 | 5/5 | 新增模式,不影响现有模式 |
| 代码侵入性 | ⅖ | 新增 ~530 行,新增 1 个 display_mode |
| 实施复杂度 | ⅕ | 7-10 天,需实现完整新模式 + 自动检测 |
| 可维护性 | ⅗ | 4 种 display_mode 增加维护面积 |
| 协议合规性 | 5/5 | 完全使用 ACP 标准机制 |
工作量估算:高(~530 行新增,7-10 天)
风险评估: - 技术风险:中高。自动检测依赖客户端信息,可能需要 fallback 机制 - 兼容性风险:低。纯新增模式 - 维护风险:中。4 种 display_mode 的维护成本
推荐¶
推荐选项 2:完整 ACP 子会话支持,分阶段实施。
推荐理由:
- 选项 1 仅是临时方案,Zed 检测到 session_id 但找不到对应 session,用户体验不佳
- 选项 2 通过
display_mode=zed提供了 Zed 专用适配,无需自动检测客户端类型 - 选项 3 已被选项 2 的
display_mode=zed方案取代,无增量价值 - 选项 2 的非 Zed 客户端零影响设计:legacy/inline/tool_box 模式行为完全不变
- 选项 2 的 Phase 1 可立即解决 P0 阻塞问题,Phase ⅔ 渐进式交付
接受的权衡:
- Phase 1 阶段 Zed 的 subagent 展开/折叠 UI 内容为空,需等待 Phase 2 完成
- display_mode=zed 需要用户显式配置,不支持自动检测客户端类型
技术设计¶
架构图¶
当前事件流¶
┌─────────────────────────────────────────────────────────────────────┐
│ 当前 ACP Subagent 事件流 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ subagent_tools.py │
│ │ │
│ ├─ SpawnSessionStart ──→ event_converter ──→ AgentMessageChunk │
│ │ │ (emoji文本) │
│ │ │ │
│ └─ SubAgentEvent ──────→ event_converter │
│ │ │
│ ├── legacy ──→ AgentMessageChunk │
│ │ (markdown headers) │
│ │ │
│ ├── inline ──→ ToolCallStart │
│ │ ToolCallProgress │
│ │ (同一 session, │
│ │ _meta = None ❌) │
│ │ │
│ └── tool_box ─→ ToolCallStart │
│ ToolCallProgress │
│ (同一 session, │
│ _meta = None ❌) │
│ │
│ Zed 客户端: _meta 为空 → subagent_session_info_from_meta() = None │
│ → 无法检测 subagent → UI 不可用 │
│ │
└─────────────────────────────────────────────────────────────────────┘
提议事件流¶
┌─────────────────────────────────────────────────────────────────────┐
│ 提议 ACP Subagent 事件流 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ initialize 握手 │
│ │ │
│ └─ YAML pool_server.subagent_display_mode 配置 │
│ ├─ "zed" → Zed 适配模式(含 _meta + 子会话) │
│ └─ 其他 → 传统模式(legacy/inline/tool_box) │
│ │
│ subagent_tools.py │
│ │ │
│ ├─ SpawnSessionStart ──→ event_converter │
│ │ │ │
│ │ ┌────────────────────┼────────────────────┐ │
│ │ │ display_mode=="zed"│ display_mode!="zed"│ │
│ │ │ │ │ │
│ │ │ ├ ToolCallStart │ └ AgentMessageChunk │ │
│ │ │ │ (含 session_info│ (保持原有行为, │ │
│ │ │ │ +tool_name ✅) │ 无 _meta ❌) │ │
│ │ │ │ │ │ │
│ │ │ └ Phase 2: │ │ │
│ │ │ 创建子 ACP │ │ │
│ │ │ session │ │ │
│ │ └────────────────────┴────────────────────┘ │
│ │ │
│ └─ SubAgentEvent ──────→ event_converter │
│ │ │
│ ┌────────────────────────────┼──────────────────────┐ │
│ │ display_mode=="zed" │ display_mode!="zed" │ │
│ │ │ │ │
│ │ ├ Phase 2: 路由到子会话 │ ├ legacy → │ │
│ │ │ session/update │ │ AgentMessageChunk │ │
│ │ │ │ │ (无 _meta) │ │
│ │ │ └ 更新父 ToolCallProgress│ │ │ │
│ │ │ (含 session_info │ ├ inline → │ │
│ │ │ +tool_name ✅) │ │ ToolCallStart │ │
│ │ │ [Phase 2] │ │ (无 _meta ❌) │ │
│ │ │ │ │ │ │
│ │ │ Phase 1: SCEvent→TCP(含_meta)│ └ tool_box → │ │
│ │ │ 其他P1丢弃(P2→子会话) │ ToolCallStart │ │
│ │ │ │ (无 _meta ❌) │ │
│ └────────────────────────────┴──────────────────────┘ │
│ │
│ ✅ 关键: display_mode=="zed" 与传统模式条件分支 │
│ display_mode=="zed" 时 SpawnSessionStart 使用 ToolCallStart(含 _meta);传统模式保持 AgentMessageChunk 不变 │
│ inline/tool_box 子级 ToolCallStart/ToolCallProgress 不携带 _meta │
│ │
│ ⚠️ 向后兼容说明:display_mode!="zed" 时 SpawnSessionStart 保持原有 AgentMessageChunk 行为,非 Zed 客户端零影响。│
│ │
└─────────────────────────────────────────────────────────────────────┘
SubagentSessionInfo 数据模型¶
from __future__ import annotations
import json
from typing import Any, Literal
from pydantic import BaseModel
# Module-level constants matching Zed's acp_thread.rs
TOOL_NAME_META_KEY: str = "tool_name"
SUBAGENT_SESSION_INFO_META_KEY: str = "subagent_session_info"
class SubagentSessionInfo(BaseModel):
"""ACP _meta 中 subagent_session_info 的 Python 表示。
与 Zed 的 AcpThread 期望格式对齐。
⚠️ WARNING: 序列化为 JSON **对象**(非 JSON 字符串)存入 _meta["subagent_session_info"]。
Zed 使用 serde_json::json!(&session_info) 写入 Value::Object,
使用 serde_json::from_value(v.clone()) 读取。
若误传为字符串,Zed 的 from_value 会静默失败(.ok() → None),
导致整个 _meta 机制失效。
"""
session_id: str
"""子 ACP session 的唯一标识符。"""
message_start_index: int
"""子会话中该 turn 的起始条目索引(0-based)。
对应 Zed SubagentSessionInfo.message_start_index (usize, required)。
"""
message_end_index: int | None = None
"""子会话中该 turn 的结束条目索引(0-based)。
对应 Zed SubagentSessionInfo.message_end_index (Option<usize>)。
"""
@classmethod
def from_meta(cls, meta: dict[str, Any]) -> SubagentSessionInfo | None:
"""从 _meta 字典中提取 SubagentSessionInfo。
Args:
meta: _meta 字典,值应为 JSON 对象(dict)。
Returns:
SubagentSessionInfo 实例,若 meta 中无 subagent_session_info 则返回 None。
⚠️ 正确格式为 dict(JSON 对象);若误传为 str(JSON 字符串),
此方法尝试作为 fallback 解析,但 Zed 端 from_value 会静默失败。
"""
raw = meta.get(SUBAGENT_SESSION_INFO_META_KEY)
if raw is None:
return None
if isinstance(raw, dict):
return cls.model_validate(raw)
if isinstance(raw, str):
# Fallback: 尝试解析 JSON 字符串(兼容错误格式)
try:
data = json.loads(raw)
return cls.model_validate(data)
except (json.JSONDecodeError, ValueError):
return None
return None
def build_subagent_meta(
session_info: SubagentSessionInfo,
tool_name: str | None = None,
) -> dict[str, Any]:
"""构建包含 SubagentSessionInfo 的 _meta 字典。
此函数为底层实现,由 `_build_subagent_field_meta()` 调用。
`_build_subagent_field_meta()` 为推荐调用入口,包含空值安全检查和 WARNING 日志。
`build_subagent_meta()` 为底层实现,直接调用不安全。
Args:
session_info: 子会话信息
tool_name: 工具名称(用于 Zed 的 tool_name 归因)
Returns:
可传入 ToolCallStart/ToolCallProgress 的 field_meta 参数的字典。
返回类型为 dict[str, Any] 以匹配 AnnotatedObject.field_meta 的类型定义。
⚠️ WARNING: subagent_session_info 的值必须为 dict(JSON 对象),
绝不能为 str(JSON 字符串)。这是 Zed 的 serde_json::from_value 要求。
常量 TOOL_NAME_META_KEY 和 SUBAGENT_SESSION_INFO_META_KEY 为模块级定义,
匹配 Zed 的 acp_thread.rs 中的常量。
"""
meta: dict[str, Any] = {
SUBAGENT_SESSION_INFO_META_KEY: session_info.model_dump(),
}
if tool_name is not None:
meta[TOOL_NAME_META_KEY] = tool_name
return meta
路由模式设计¶
Subagent 事件路由通过 display_mode 配置控制。新增 zed 模式专为 Zed 编辑器适配,为 Zed 专属的 _meta.subagent_session_info 扩展机制提供支持。
zed 模式是 ad-hoc 适配,非 ACP 协议标准特性。其他 ACP 客户端无需支持此模式,使用 legacy/inline/tool_box 即可。
四种 display_mode 对比¶
| display_mode | _meta 填充 | 子会话创建 | 适用场景 |
|---|---|---|---|
| legacy | ❌ | ❌ | 默认模式,扁平化文本 |
| inline | ❌ | ❌ | 每个 subagent 独立 ToolCall |
| tool_box | ❌ | ❌ | 累积式工具箱 |
| zed | ✅ | ✅ (Phase 2) | Zed 编辑器 subagent UI 适配 |
YAML 配置¶
单一配置项,无需额外的路由配置。使用现有 pool_server.subagent_display_mode 配置键,仅新增 "zed" 枚举值。无需额外配置键或迁移。
⚠️ 配置注意事项¶
"zed"模式专为 Zed 编辑器设计,非 Zed 客户端可能无法正确渲染_meta.subagent_session_info扩展字段- 服务器启动时若检测到
display_mode == "zed",应输出 WARNING 日志:"Subagent display_mode='zed' is designed for Zed editor clients only. Other clients may not render subagent UI correctly." "zed"永远不会作为默认值 — 必须用户显式配置。若未配置subagent_display_mode或配置为legacy/inline/tool_box,subagent 行为与当前完全一致。
@dataclass
class ACPEventConverter:
_display_mode: Literal["legacy", "inline", "tool_box", "zed"] = "legacy"
_session_manager: SessionManager | None = None # Phase 2 新增
_subagent_tool_map: dict[str, str] = field(default_factory=dict)
# 注:Phase 3 将新增 _subagent_message_counts: dict[str, int] = field(default_factory=dict)
⚠️ _meta 填充约束¶
关键规则:_meta.subagent_session_info 仅在 display_mode == "zed" 时,由 SpawnSessionStart 产出的 ToolCallStart 及其后续 ToolCallProgress 中设置。_convert_subagent_inline / _convert_subagent_tool_box 产出的子级 ToolCallStart / ToolCallProgress **不得**携带任何 _meta 字段。
此约束仅在 display_mode == "zed" 时有意义。其他 display_mode 下不填充任何 _meta。
原因:Zed 的 is_subagent() 通过 subagent_session_info.is_some() 检测 subagent。如果子级 ToolCall 也携带此字段,Zed 会将所有子级 ToolCall 视为独立的 subagent 父级,导致 UI 渲染混乱。
inline/tool_box 子级 ToolCallStart/ToolCallProgress **不得**携带任何 _meta 字段。仅 display_mode == "zed" 时 SpawnSessionStart 产出的 ToolCallStart/ToolCallProgress 携带 _meta(含 subagent_session_info + tool_name)。
tool_name选择说明:SpawnSessionStart 产出的 ToolCallStart 使用tool_name="task",这是 AgentPool 中创建子代理的工具名(定义于subagent_tools.py),与现有 subagent_tools.py 保持一致。Zed 使用_meta.tool_name在 UI 中显示工具归因。
事件流规范¶
SpawnSessionStart → ToolCallStart(带 _meta)¶
当 display_mode == "zed" 时,SpawnSessionStart 事件到达时,event_converter 应:
- 发射
ToolCallStart,field_meta包含subagent_session_info和tool_name - 记录
tool_call_id与child_session_id的映射关系 - 创建子 ACP session(Phase 2)
当 display_mode != "zed" 时,SpawnSessionStart 保持原有 AgentMessageChunk 行为不变。
# event_converter.py — SpawnSessionStart 处理(提议)
case SpawnSessionStart(
child_session_id=child_id,
parent_session_id=parent_id,
tool_call_id=tc_id,
source_name=source_name,
spawn_mechanism=mechanism,
description=description,
):
if self._display_mode == "zed":
# zed 模式:发射带 _meta 的 ToolCallStart
meta = self._build_subagent_field_meta(child_session_id=child_id, tool_name="task")
# Phase 2: 创建子 ACP session
# await self._session_manager.new_session(
# session_id=child_id,
# parent_session_id=parent_id,
# )
# 记录映射
self._subagent_tool_map[child_id] = tc_id or f"spawn:{child_id}"
icon = "⚡" if mechanism == "spawn" else "🚀"
yield ToolCallStart(
tool_call_id=tc_id or f"spawn:{child_id}",
title=f"{icon} `{source_name}`: {description}",
kind="other",
status="in_progress",
field_meta=meta,
)
else:
# 非 zed 模式:保持原有 AgentMessageChunk 行为
icon = "⚡" if mechanism == "spawn" else "🚀"
yield AgentMessageChunk.text(f"{icon} `{source_name}`: {description}")
SubAgentEvent 处理(Phase 1)¶
实际实现不使用独立的 _convert_subagent_event() 方法,而是拆分在 convert() 方法的两个位置:
- SpawnSessionStart → 见上方
convert()方法的 SpawnSessionStart case(lines 637-671) - StreamCompleteEvent → 见下方
convert()方法的 SubAgentEvent case 中 StreamCompleteEvent 分支(lines 688-697)
inline/tool_box 子级 ToolCallStart/ToolCallProgress 修改在现有 _convert_subagent_inline / _convert_subagent_tool_box 方法中进行(子级 ToolCall 不携带 _meta,无需修改)。
StreamCompleteEvent → ToolCallProgress(完成父级 ToolCall)¶
⚠️ StreamCompleteEvent 本身不含 child_session_id 字段。子代理完成事件通过 SubAgentEvent 包装传递,child_session_id 在 SubAgentEvent 外层。
当 display_mode == "zed" 时,子代理完成时 SubAgentEvent(event=StreamCompleteEvent(...)) 到达。在 convert() 方法的 SubAgentEvent case 分支内,通过匹配 inner_event 类型处理,使用 SubAgentEvent.child_session_id 从 _subagent_tool_map 查找对应的 tool_call_id,发射 ToolCallProgress 完成父级 ToolCall。当 display_mode != "zed" 时,不发射 ToolCallProgress(无父级 ToolCallStart 需要完成)。
# event_converter.py — StreamCompleteEvent 处理(Phase 1 提议)
# 在 convert() 方法的 SubAgentEvent case 分支内:
case SubAgentEvent(child_session_id=child_id, tool_call_id=_, event=inner_event)
if self._display_mode == "zed" and child_id in self._subagent_tool_map
and isinstance(inner_event, StreamCompleteEvent):
# Phase 1: 仅处理 StreamCompleteEvent 以完成父级 ToolCall
# 非 StreamCompleteEvent 的 SubAgentEvent(文本输出、工具调用等)在 Phase 1 中丢弃
# Phase 2 将这些事件路由到子 ACP session
tc_id = self._subagent_tool_map[child_id]
meta = self._build_subagent_field_meta(child_session_id=child_id, tool_name="task")
yield ToolCallProgress(tool_call_id=tc_id, status="completed", field_meta=meta)
各 display_mode 行为:
- display_mode == "zed":发射
ToolCallProgress(status="completed", field_meta=meta)完成父级 ToolCall - display_mode != "zed":不发射 ToolCallProgress(无父级 ToolCallStart 需要完成)
SubAgentEvent → 子会话路由(Phase 2)¶
# event_converter.py — SubAgentEvent 处理(提议,Phase 2)
case SubAgentEvent(
source_name=source_name,
source_type=source_type,
event=inner_event,
depth=depth,
child_session_id=child_id,
parent_session_id=parent_id,
tool_call_id=tc_id,
):
if self._display_mode == "zed":
# zed 模式:事件路由到子会话
# async for update in self._convert_event_to_session_update(inner_event):
# await self._session_manager.send_session_update(child_id, update)
# ⚠️ Zed 使用 num_entries.saturating_sub(1) 计算 end_index(0-based index,非 count)
# message_end_index=message_count - 1 if message_count > 0 else None
# ...
pass # Phase 2 实现
else:
# 传统模式:使用 legacy/inline/tool_box 转换
match self._display_mode:
case "inline":
async for update in self._convert_subagent_inline(
source_name, source_type, inner_event, depth,
child_session_id=child_id, tool_call_id=tc_id,
):
yield update
case "tool_box":
async for update in self._convert_subagent_tool_box(
source_name, source_type, inner_event, depth,
child_session_id=child_id, tool_call_id=tc_id,
):
yield update
case "legacy":
async for update in self._convert_subagent_legacy(
source_name, source_type, inner_event, depth,
child_session_id=child_id, tool_call_id=tc_id,
):
yield update
API 变更¶
event_converter.py 方法签名变更¶
import logging
from typing import Any, Literal
from .subagent_meta import SubagentSessionInfo, build_subagent_meta
logger = logging.getLogger(__name__)
@dataclass
class ACPEventConverter:
"""ACP event converter with subagent _meta support."""
# 以下字段为 dataclass 字段声明(ACPEventConverter 为 @dataclass 类)
_display_mode: Literal["legacy", "inline", "tool_box", "zed"] = "legacy"
_session_manager: SessionManager | None = None # Phase 2 新增
# Phase 1 新增
_subagent_tool_map: dict[str, str] = field(default_factory=dict) # child_session_id → tool_call_id
# 注:Phase 3 将新增 _subagent_message_counts: dict[str, int] = field(default_factory=dict)
def _build_subagent_field_meta(
self,
child_session_id: str,
tool_name: str | None = None,
message_start_index: int = 0,
message_end_index: int | None = None,
) -> dict[str, Any] | None:
"""为 subagent 工具调用构建 _meta 字段。
Args:
child_session_id: 子会话 ID
tool_name: 底层工具名称
message_start_index: 起始消息索引(默认 0,假设子会话总是新建的)
message_end_index: 结束消息索引
Returns:
field_meta 字典,若 child_session_id 为空则返回 None。
"""
if not child_session_id:
logger.warning("build_subagent_field_meta called with empty child_session_id, skipping _meta fill")
return None
session_info = SubagentSessionInfo(
session_id=child_session_id,
message_start_index=message_start_index,
message_end_index=message_end_index,
)
return build_subagent_meta(session_info, tool_name=tool_name)
两层默认值说明:
event_converter.py的字段默认值为"legacy"(通过_get_display_mode()环境变量回退),而server.py/session_manager.py在构造时传入"tool_box"作为参数默认值。实际运行时默认为"tool_box",但 converter 层面的字段默认值必须保持"legacy"以匹配源码行为。
reset() 行为变更¶
reset() 方法在 StreamCompleteEvent 时被调用,用于清理单次 prompt 响应的状态。但 _subagent_tool_map 和 _subagent_message_counts 是**跨 prompt 生命周期**的状态(一个 subagent 可能跨多个 prompt 运行),不应被 reset() 清除。
def reset(self) -> None:
"""重置单次 prompt 响应的状态,但保留跨 prompt 的 subagent 追踪状态。"""
# 清理单次响应状态(使用实际 dataclass 字段)
self._current_tool_inputs.clear()
self._tool_states.clear()
# ... 其他单次响应状态清理
# ⚠️ 不清理 subagent 追踪状态
# self._subagent_tool_map 保留 — 在父 session close 时清理
# self._subagent_message_counts 保留 — 在父 session close 时清理 [Phase 3 新增]
⚠️ 当前 event_converter.py 存在 reset() body 重复声明(字段赋值与 dataclass field(default_factory=...) 重复)和 reset() 被调用两次的 bug。Phase 1 应一并清理。
_subagent_tool_map(及 Phase 3 的 _subagent_message_counts)应在父会话 session/close 时清理,而非 reset() 时。
此外,当前 event_converter.py 存在 self.reset() 在 StreamCompleteEvent handler 中被调用两次的 bug(lines 670, 673),应在 Phase 1 中一并修复。
session/close 清理¶
_subagent_tool_map 的生命周期与父会话绑定,应在父会话 session/close 时显式清理:
设计理由:_subagent_tool_map 跨多个 prompt 生命周期持久存在,reset() 不应清除它。清理路径必须显式绑定到 session/close,确保会话结束时不会遗留过期的映射条目。
EventConverter 清理钩子¶
ACPEventConverter 添加 cleanup() 方法,在 AcpSession.close() 中调用:
# event_converter.py
def cleanup(self) -> None:
"""清理会话级别的持久状态。在 session/close 时由 AcpSession 调用。"""
for child_id in list(self._subagent_tool_map.keys()):
# Phase 2: await self._session_manager.close_session(child_id)
del self._subagent_tool_map[child_id]
# Phase 3: for child_id in list(self._subagent_message_counts.keys()):
# del self._subagent_message_counts[child_id]
⚠️ Phase 2 变更:
cleanup()将变更为async def cleanup(),AcpSession.close()中需改为await self._converter.cleanup()。
# session.py — AcpSession.close() 中添加
def close(self) -> None:
# ... 现有清理逻辑 ...
if self._converter is not None:
self._converter.cleanup()
设计理由:ACPEventConverter 没有 session 生命周期感知,cleanup() 提供显式的会话结束信号,避免 _subagent_tool_map 在长期运行会话中造成内存泄漏。
session_manager.py 变更(Phase 2)¶
class SessionManager:
"""ACP session lifecycle management."""
async def new_session(
self,
session_id: str | None = None,
parent_session_id: str | None = None,
cwd: str | None = None,
) -> AcpSession:
"""创建新的 ACP session。
Args:
session_id: 可选的 session ID(用于子会话预分配)
parent_session_id: 父会话 ID(用于 subagent 关联)
cwd: 工作目录
Returns:
新创建的 AcpSession 实例。
"""
...
async def send_session_update(
self,
session_id: str,
update: ACPSessionUpdate,
) -> None:
"""向指定 session 发射 session update 事件。
用于将子会话的事件通过 ACP 传输层发送给客户端。
Args:
session_id: 目标 session ID
update: ACP session update 对象
"""
...
async def get_message_count(
self,
session_id: str,
) -> int:
"""获取指定 session 的当前消息数量。
用于计算 message_end_index。
Args:
session_id: 目标 session ID
Returns:
当前消息条目数量。
"""
...
子会话生命周期状态机¶
┌──────────┐ SpawnSessionStart ┌──────────┐
│ 不存在 │ ──────────────────→ │ 已创建 │
│ │ │ (active) │
└──────────┘ └────┬─────┘
│
SubAgentEvent │ (事件路由到子会话)
│
▼
┌──────────┐
┌─────────────│ 运行中 │
│ │ (running)│
│ └────┬─────┘
│ │
StreamCompleteEvent │ 父会话取消
(成功/错误) │ (session/cancel)
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 已完成 │ │ 已取消 │
│ (closed) │ │ (closed) │
└──────────┘ └──────────┘
异常路径:
- 子会话运行时父会话 session/close → 遍历所有子会话,执行 close
- ACP 服务器崩溃 → 重启后通过 SessionManager.get_child_sessions() 检测孤立会话
- 子会话创建后 SubAgentEvent 未到达 → 子会话超时关闭(建议 5 分钟)
状态转换规则:
SpawnSessionStart→ 创建子会话 (active)SubAgentEvent→ 事件路由到子会话 (running)StreamCompleteEvent(success)→ 子会话关闭 (closed),父 ToolCallProgress → completedStreamCompleteEvent(error)→ 子会话关闭 (closed),父 ToolCallProgress → failed- 父
session/cancel→ 所有子会话 cancelled + closed - 父
session/close→ 所有子会话 closed - 超时 → 子会话 closed,父 ToolCallProgress → failed
安全考量¶
-
Session ID 注入:
SubagentSessionInfo.session_id由服务端生成,不接受客户端输入。使用identifier.ascending("session")生成,确保不可预测。 -
子会话隔离:子会话继承父会话的权限范围。不允许子会话访问父会话之外的资源。工作目录(
cwd)与父会话一致。 -
_meta 数据泄露:
_meta字段通过 ACP JSON-RPC 传输,可能被客户端日志记录。不应在_meta中包含敏感信息(如 API key、用户凭证)。SubagentSessionInfo仅包含 session ID 和索引,无安全风险。 -
拒绝服务:恶意客户端可能通过频繁触发 subagent 创建大量子会话。需实施子会话数量上限(建议与 Zed 的
MAX_SUBAGENT_DEPTH=1对齐,即每个父会话最多 N 个并发子会话)。 -
子会话生命周期:子会话必须在父会话关闭时一并关闭,避免孤立会话占用资源。在
session/close处理中遍历并关闭所有子会话。
实施计划¶
Phase 0:子会话 session/update 传输验证 — Spike(0.5-1 天)¶
目标:验证 Zed 能否接收通过父会话 client 连接发送的子会话 session/update
范围:
- [ ] 创建 PoC:在 ACP process_prompt 中手动创建子 session,发送 session/update
- [ ] 验证 Zed 能否收到并处理带有子 session_id 的 update notification
- [ ] 验证 Zed 是否需要通过 session/new 预先知晓子会话
- [ ] 如需 session/new,验证服务端代理调用的可行性
- [ ] 输出:技术可行性报告,确定 Phase 2 的传输路径设计
Go/No-Go 准则:
- Go:Zed 能接收通过父会话连接发送的、session_id 不同于父会话的 session/update notification(无需先 session/new)
- Go(替代路径):Zed 需要 session/new 知晓子会话,但 ACP 传输层支持服务端主动发送 session/new 响应给客户端
- No-Go:Zed 既不接受未知 session_id 的 session/update,ACP 传输层也不支持服务端主动调用 session/new → Phase 2 必须使用替代设计(将子会话内容嵌入父 ToolCallProgress 的 content 字段)
预估工期:0.5-1 天 依赖:无 阻塞:Phase 2 的设计依赖此 spike 的结果
Phase 1:填充 _meta(SubagentSessionInfo + tool_name)— P0¶
目标:G1 + G2,使 Zed 可检测 subagent 工具调用
范围:
- [ ] 创建 SubagentSessionInfo 数据模型(~40 行)
- [ ] 创建 build_subagent_meta() 和 _build_subagent_field_meta() 辅助方法(~35 行)
- [ ] 增加 ACPEventConverter 字段声明 _subagent_tool_map(~5 行)
- [ ] 修改 SpawnSessionStart 处理:当 display_mode == "zed" 时发射带 _meta 的 ToolCallStart;当 display_mode != "zed" 时保持原有 AgentMessageChunk 行为不变
- [ ] 修改 StreamCompleteEvent 处理:当 display_mode == "zed" 时对 SpawnSessionStart 产出的 ToolCallStart 发射 ToolCallProgress (status=completed)
- [ ] 非 StreamCompleteEvent 的 SubAgentEvent 在 display_mode == "zed" 时丢弃(Phase 2 路由到子会话)
- [ ] 修改 _convert_subagent_inline 中所有 ToolCallStart/ToolCallProgress 调用,不变(子级 ToolCall 不携带 _meta)
- [ ] 修改 _convert_subagent_tool_box 中所有 ToolCallStart/ToolCallProgress 调用,不变(子级 ToolCall 不携带 _meta)
- [ ] 编写测试:验证 display_mode != "zed" 时 ToolCallStart.field_meta 为 None(无 subagent_session_info 泄露)
- [ ] 编写测试:验证 inline/tool_box 子级 ToolCallStart.field_meta 为 None
- [ ] 编写测试:验证仅 display_mode == "zed" 时 SpawnSessionStart 产出的 ToolCallStart.field_meta 包含 subagent_session_info
- [ ] 编写测试:验证 display_mode == "zed" 时 StreamCompleteEvent 产出的 ToolCallProgress.field_meta 包含 subagent_session_info
- [ ] 编写测试:验证 display_mode == "zed" 时非 StreamCompleteEvent 的 SubAgentEvent(如文本输出事件)不产生任何 ACP 事件(Phase 1 丢弃行为)
- [ ] 为 SubagentSessionInfo 和 build_subagent_meta 编写单元测试
- [ ] 添加 ACP 快照测试,验证 subagent 工具调用的完整 JSON-RPC 输出格式
- [ ] 快照测试对比 emitted JSON-RPC messages 与参考文件,确保 _meta.subagent_session_info 为 JSON 对象(非字符串)
- [ ] 修复 reset() 在 StreamCompleteEvent 中被调用两次的 bug
- [ ] 清理 reset() body 中与 dataclass field(default_factory=...) 重复的字段赋值声明
- [ ] 确认 reset() 不清除 _subagent_tool_map
- [ ] 修复 subagent_tools.py 中 SpawnSessionStart 双重发射 bug(task() 和 _stream_task() 均 emit,同步模式下产生两个 ToolCallStart)
- [ ] 在 session/close 处理中清理 _subagent_tool_map
- [ ] 在 ACPEventConverter 中添加 cleanup() 方法
- [ ] 在 AcpSession.close() 中调用 converter.cleanup()
类型定义传播清单¶
添加 "zed" 到 display_mode Literal 类型需更新以下文件:
| 文件 | 当前类型 | 变更 |
|---|---|---|
server.py |
SubagentDisplayMode = Literal["inline", "tool_box"] |
添加 "zed" |
event_converter.py (2处) |
Literal["legacy", "inline", "tool_box"] |
添加 "zed" |
pool_server.py |
Literal["inline", "tool_box"] |
添加 "zed" |
session.py |
Literal["inline", "tool_box"] |
添加 "zed" |
session_manager.py (2处) |
Literal["inline", "tool_box"] |
添加 "zed" |
acp_agent.py |
Literal["inline", "tool_box"] |
添加 "zed" |
serve_acp.py |
CLI argument choices | 添加 "zed" |
- 更新上述所有文件中的 display_mode Literal 类型,添加
"zed"值 - 更新
server.py中_coerce_subagent_display_mode()处理"zed"值 - 更新
event_converter.py中_get_display_mode()验证逻辑,接受"zed"
关于 "legacy" 类型差异:
event_converter.py的_display_mode类型包含 "legacy"(通过环境变量_get_display_mode()默认值),但server.py/pool_server.py等服务器层类型不包含 "legacy"。"legacy" 是 event_converter 内部回退值,非 YAML 配置可选项。本次仅新增 "zed",不改变 "legacy" 的现有分布。
⚠️ 嵌套 SpawnSessionStart 说明:当前 _convert_subagent_legacy 对嵌套 SpawnSessionStart 执行 pass(丢弃),_convert_subagent_inline / _convert_subagent_tool_box 未处理该事件(走默认分支)。Phase 1 不修改此行为(Zed 限制 MAX_SUBAGENT_DEPTH=1,嵌套场景罕见)。Phase 2 应添加嵌套 SpawnSessionStart 的 ToolCallStart 处理。
预估代码量:~130 行新增
预估工期:1-2 天
依赖:无
回滚策略:还原 event_converter.py 中的 field_meta 参数即可
Phase 2:子会话创建与事件路由 — P1¶
目标:G3,创建独立子 ACP session,路由事件
范围:
- [ ] 修改 ACPEventConverter.__init__ 接收 session_manager 参数
- [ ] 当 display_mode == "zed" 时,SpawnSessionStart 处理中调用 session_manager.new_session() 创建子会话
- [ ] 当 display_mode == "zed" 时,SubAgentEvent 处理将 inner_event 路由到子会话
- [ ] ToolCallProgress 必须携带 subagent_session_info(与 ToolCallStart 保持一致)
- [ ] 处理 StreamCompleteEvent:更新父 ToolCallProgress 状态为 completed,关闭子会话
- [ ] 修改 session_manager.py:支持 parent_session_id 参数和 send_session_update 方法
- [ ] 处理子会话关闭时的资源清理
- [ ] 编写集成测试
- [ ] 添加 ACP 快照测试,验证子会话 session/update 的传输格式
预估代码量:~300 行新增
预估工期:3-5 天
依赖:Phase 1
回滚策略:移除 session_manager 参数和子会话路由逻辑,回退到 Phase 1 行为
Phase 3:message_start_index / message_end_index 追踪 — P2¶
目标:G4,提供精确的消息范围索引
范围:
- [ ] 在 ACPEventConverter 中维护 _subagent_message_counts 映射
- [ ] 子会话创建时记录 message_start_index
- [ ] 每次 inner_event 路由后更新 message_end_index
- [ ] 在 ToolCallProgress 的 field_meta 中更新 message_end_index
- [ ] ⚠️ message_end_index 使用 0-based index(匹配 Zed 的 saturating_sub(1) 语义),即 message_count - 1 if message_count > 0 else None
- [ ] 编写单元测试验证索引正确性
- [ ] 确保 message_start_index 始终有值(子会话创建时设为 0)
假设:本 RFC 假设子会话总是新建的(非恢复),因此 message_start_index 始终为 0。若未来支持子会话恢复,需更新此值为 session.get_entry_count()。
预估代码量:~80 行新增
预估工期:1-2 天
依赖:Phase 2
回滚策略:移除 _subagent_message_counts 和索引更新逻辑
里程碑总览¶
| Phase | 目标 | 工期 | 累计 |
|---|---|---|---|
| Phase 0 | Spike: 传输验证 | 0.5-1 天 | 0.5-1 天 |
| Phase 1 | P0 阻塞解决:_meta 填充 |
1-2 天 | 1.5-3 天 |
| Phase 2 | 子会话创建、路由、display_mode=zed 子会话路由 | 3-5 天 | 4.5-8 天 |
| Phase 3 | Message index 追踪 | 1-2 天 | 5.5-10 天 |
依赖关系¶
Phase 0 与 Phase 1 可并行执行。Phase 0 仅阻塞 Phase 2(需要传输验证结果),Phase 1 可立即开始。
开放问题¶
-
子会话 session update 的发射路径:ACP 传输层当前仅支持从活跃 session(即正在执行
session/prompt的 session)发射 update。子会话的 update 是否需要通过父会话的 JSON-RPC 连接中继?这需要确认 ACP JSON-RPC 传输层的具体实现。Phase 0 Spike 将验证此路径。 -
嵌套 subagent 的消息索引:当 subagent 本身也调用 subagent 时(虽然 Zed 限制
MAX_SUBAGENT_DEPTH=1,但 AgentPool 内部可能有多层),message_start_index/message_end_index的语义是否仍与 Zed 期望一致? -
非 Zed 客户端的
_meta兼容性:填充_meta后,其他 ACP 客户端(如 JetBrains IDE、VS Code ACP 适配器)如何处理未知的_meta字段?根据 ACP 协议规范,客户端应忽略未知的_metakey,但需验证实际行为。 -
Zed 版本兼容性:
_meta.subagent_session_info格式是否为 Zed 稳定接口?若 Zed 在未来版本更改格式,AgentPool 需要如何适配? -
子会话的超时清理策略:若
SubAgentEvent长时间未到达(如 LLM 响应超时),子会话应在多少时间后自动关闭? -
子会话的 session/prompt(已解决):AgentPool 的子会话**不支持**独立的
session/prompt调用。子会话仅作为事件接收容器,通过send_session_update()接收父会话路由的事件。这与 Zed 的SubagentHandle.send()内部 API 语义一致(Zed 的子会话不通过 ACPsession/prompt发送消息)。
决策记录¶
| 日期 | 决策 | 理由 |
|---|---|---|
| 2026-04-24 | 推荐选项 2(完整 ACP 子会话支持) | 平衡了 Zed 兼容性、代码侵入性和可维护性 |
| 2026-04-24 | Phase 1 优先交付 | P0 阻塞问题需立即解决 |
| 2026-04-24 | 不推荐选项 1 作为最终方案 | 临时方案,Zed 子会话内容为空,用户体验不佳 |
| 2026-04-24 | subagent_session_info 为 JSON 对象(非字符串) | Oracle 审查确认 Zed 使用 serde_json::json! 写入 Value::Object |
| 2026-04-24 | SubagentRoutingMode 机制替换为 display_mode=zed | 用户决策:subagent 适配是 Zed 专属 ad-hoc 扩展,应作为显式配置项。Oracle R3/Metis R4/用户决策 |
| 2026-04-24 | display_mode=zed 时 SpawnSessionStart 使用 ToolCallStart(含 _meta);其他 display_mode 保持 AgentMessageChunk 不变 | 非 Zed 客户端零影响设计 |
| 2026-04-24 | 移除 _routing_mode 传播链,display_mode=zed 通过现有 _display_mode 传播 | Metis F1 (Round 3)/Oracle R4/Metis R4/用户决策 |
| 2026-04-24 | Zed 客户端通过 display_mode=zed 显式配置激活适配,非 Zed 客户端保持 legacy 默认 | legacy(非 tool_box)为当前默认 display_mode;display_mode != "zed" 时 SpawnSessionStart 行为不变 |
| 2026-04-24 | 桥接核心 SessionManager.create_child_session() | Metis 审查发现已有基础设施,避免重建 |
| 2026-04-24 | 仅 SpawnSessionStart 的 ToolCallStart 携带 subagent_session_info | Metis S2/F1:子级 ToolCall 携带 subagent_session_info 会导致 Zed 将子级视为独立 subagent 父级,UI 渲染混乱 |
| 2026-04-24 | _meta key 使用 snake_case(tool_name 而非 toolName) | Oracle S1/Metis P1:Zed 的 TOOL_NAME_META_KEY = "tool_name" 为 snake_case |
| 2026-04-24 | YAML 配置简化为 pool_server.subagent_display_mode 单层结构(4 值) | Oracle M3/Metis S3:display_mode=zed 替代原 routing_mode + display_mode 双层结构 |
| 2026-04-24 | 子会话不支持独立 session/prompt 调用 | Metis M2:子会话仅作为事件接收容器,与 Zed SubagentHandle.send() 语义一致 |
| 2026-04-24 | reset() 不清除 subagent 追踪状态 | Metis F2:_subagent_tool_map 和 _subagent_message_counts 为跨 prompt 生命周期状态 |
| 2026-04-24 | Phase 0 与 Phase 1 可并行执行,Phase 0 仅阻塞 Phase 2 | Oracle M1:Phase 0 的 spike 验证结果仅 Phase 2 需要 |
| 2026-04-24 | 当前默认 display_mode 为 legacy(非 tool_box) | Oracle S1 (Round 3):源码确认 legacy 为默认模式,tool_box 不是默认 |
| 2026-04-24 | routing_mode 移至 Phase 2 里程碑 → 已替换为 display_mode=zed | Oracle M2/Metis M2 (Round 3):Phase 1 仅在 display_mode==zed 时填充 _meta;Phase 2 添加子会话路由 |
| 2026-04-24 | ACPEventConverter 使用 @dataclass 字段声明而非 init body | Metis S2 (Round 3):dataclass 类中状态字段必须使用 field(default_factory=...) 声明 |
| 2026-04-24 | inline/tool_box 子级 ToolCall 不携带 _meta 字段 | Round 7 审查 + 用户决策:仅 SpawnSessionStart 的 ToolCallStart 携带 _meta;子级 ToolCall 不携带 _meta 简化实现并避免 Zed UI 混乱 |
| 2026-04-24 | SubAgentEvent 处理器通过 _display_mode == "zed" guard 区分路由 | Metis S4 (Round 3):zed 模式路由到子会话,其他 display_mode 使用传统转换 |
| 2026-04-24 | message_end_index 使用 0-based index(saturating_sub(1) 语义) | Oracle M3 (Round 3):Zed 使用 num_entries.saturating_sub(1),非 count |
| 2026-04-24 | message_start_index 硬编码为 0(非 current_entry_count) | Metis F2 (Round 3):Phase ½ 子会话新建,始终为 0;Phase 3 需改为 session.get_entry_count() |
| 2026-04-24 | _build_subagent_field_meta() 为主要辅助方法 | Metis M3 (Round 3):更安全的 API(None fallback),build_subagent_meta() 为底层实现 |
| 2026-04-24 | GAP 1 行号区分 subagent vs 非 subagent ToolCall | Metis M4 (Round 3):非 subagent ToolCall 不得传入 subagent_session_info |
| 2026-04-24 | Zed 源码片段更新为更准确的表示 | Oracle M4 (Round 3):包含 TOOL_NAME_META_KEY 常量定义、spawn 时 message_end_index=None、完成时 saturating_sub(1) |
| 2026-04-24 | TOOL_NAME_META_KEY 提升为模块级常量 | Oracle S1/Metis F1 (Round 4):build_tool_name_meta() 引用局部变量会导致 NameError(⚠️ build_tool_name_meta() 已在 Round 7 移除) |
| 2026-04-24 | _display_mode 字段默认值修正为 "legacy" | Oracle M1/Metis S1 (Round 4):源码 _get_display_mode() 默认返回 "legacy",code example 使用 "tool_box" 为行为回归 |
| 2026-04-24 | 概述注释拆分 Phase 1/Phase 2 | Metis M1 (Round 4):避免 Phase 1 引用 Phase 2 的路由机制概念 |
| 2026-04-24 | Phase 1 scope 移除 _subagent_message_counts | Metis M2 (Round 4):该字段为 Phase 3 新增,Phase 1 不应引用 |
| 2026-04-24 | 架构图 _meta 标记区分内容类型 | Metis M3 (Round 4):session_info+tool_name 与无 _meta 的区别(Round 7 更新:inline/tool_box 子级 ToolCall 不携带 _meta) |
| 2026-04-24 | message_start_index 注释修正为未来考虑 | Oracle M2 (Round 4):Phase 3 scope 和假设均保持 0,非 Phase 3 交付项 |
| 2026-04-24 | _display_mode 类型更新为 Literal["legacy", "inline", "tool_box", "zed"] | Oracle M3 (Round 4):匹配更新后的类型注解,新增 zed 枚举值 |
| 2026-04-24 | SpawnSessionStart 行为条件化:display_mode==zed 使用 ToolCallStart(含 _meta),其他保持 AgentMessageChunk | R5 决策修订:非 Zed 客户端零影响,display_mode != "zed" 时 SpawnSessionStart 行为不变 |
| 2026-04-24 | tool_name 使用 "task"(AgentPool 子代理工具名) | R6 决策:"task" 是 AgentPool 中创建子代理的工具名(定义于 subagent_tools.py),与现有命名保持一致 |
| 2026-04-24 | YAML 配置简化为 pool_server.subagent_display_mode 单层结构(4 值:legacy/inline/tool_box/zed) | R7 决策修订:display_mode=zed 替代原 routing_mode + display_mode 双层结构 |
| 2026-04-24 | Phase 2 ToolCallProgress 与 ToolCallStart 保持一致的 _meta 内容 | R8 决策:ToolCallProgress 必须携带 subagent_session_info,确保 Zed subagent 卡片在 progress 事件时仍能获取会话信息 |
| 2026-04-24 | SubagentRoutingMode 枚举已整体移除,display_mode=zed 替代其全部功能 | R3 决策修订:display_mode=zed 替代原 SubagentRoutingMode 枚举,用户显式配置,无需自动检测 |
| 2026-04-24 | Phase 1 不处理嵌套 SpawnSessionStart | R4 决策:Zed 限制 MAX_SUBAGENT_DEPTH=1,嵌套场景罕见;Phase 2 应补充嵌套 SpawnSessionStart 的 ToolCallStart 处理 |
| 2026-04-24 | Phase 1 修复 subagent_tools.py 双重 SpawnSessionStart 发射 | Oracle P1-3 (Round 7):task() 和 _stream_task() 均会 emit SpawnSessionStart,同步模式下产生两个 ToolCallStart |
| 2026-04-24 | model_dump() 替代 model_dump(exclude_none=True) | Metis P2-4 (Round 7):exclude_none 会省略 message_end_index=None,Zed Rust struct 可能缺少 #[serde(default)] |
| 2026-04-24 | Phase 1 在 session/close 中清理 _subagent_tool_map | Oracle P1-2 (Round 7):_subagent_tool_map 生命周期与父会话绑定,需显式清理路径 |
| 2026-04-24 | 架构图 Phase 1 zed 分支补充 StreamCompleteEvent → ToolCallProgress(含 _meta) | R1 修订 (P1-1):原描述"非 SpawnSessionStart 事件与传统模式相同"不准确,Phase 1 zed 模式下 StreamCompleteEvent 也产出带 _meta 的 ToolCallProgress |
| 2026-04-24 | 移除概念性 _convert_subagent_event() 代码块,改为引用 convert() 中的实际代码位置 | R2 修订 (P1-2):概念性方法与其免责声明矛盾(免责声明称实现在 convert() 中),统一为实际代码位置引用消除歧义 |
| 2026-04-24 | StreamCompleteEvent 处理模式匹配中 tool_call_id=tc_id 改为 tool_call_id=_ | R3 修订 (P2-1):tc_id 从模式绑定后立即被 _subagent_tool_map[child_id] 遮蔽,改为 _ 消除变量遮蔽 |
| 2026-04-24 | cleanup() 方法标注 Phase 2 将变更为 async def,AcpSession.close() 需 await | R4 修订 (P2-2):Phase 2 需在 cleanup() 中 await 子会话关闭,同步→异步为 breaking change,需预先标注 |
| 2026-04-24 | API 变更部分添加 Any 类型导入和 subagent_meta 模块导入 | R5 修订 (P2-3):_build_subagent_field_meta 返回 dict[str, Any] 需 Any 导入;SubagentSessionInfo/build_subagent_meta/常量需从 .subagent_meta 导入 |
| 2026-04-24 | Phase 1 guardrail 测试项拆分为 3 条可验证的具体测试 | R6 修订 (P2-4):原 guardrail 描述为行为约束而非可验证测试项,拆分为 display_mode≠zed 泄露检查、子级 _meta 检查、zed 模式正向检查 |
| 2026-04-24 | Phase 1 zed 模式下非 StreamCompleteEvent 的 SubAgentEvent 显式丢弃 | R11-1:用户决策:Phase 1 仅处理 StreamCompleteEvent,其他事件丢弃;Phase 2 路由到子会话 |
| 2026-04-24 | isinstance(inner_event, StreamCompleteEvent) 添加到外层 guard | R11-2:Oracle P1-1/Metis M11-1:避免非 StreamCompleteEvent 被静默匹配后丢弃 |
参考¶
调研文档¶
- Zed ACP Subagent 功能调研报告 — 完整的 Zed subagent 实现分析和 AgentPool 差距对比
Zed 源码¶
~/src/zed/crates/agent/src/tools/spawn_agent_tool.rs— Zed 的 spawn_agent tool:通过 _meta 发射 SubagentSessionInfo~/src/zed/crates/acp_thread/src/acp_thread.rs— Zed 客户端:从 _meta 提取 SubagentSessionInfo 以渲染 subagent UI~/src/zed/crates/agent_ui/src/acp/thread_view/active_thread.rs— Zed subagent 卡片 UI 渲染
ACP Schema 文件¶
packages/agentpool/src/acp/schema/base.py—AnnotatedObject基类,field_meta字段(序列化为_meta)packages/agentpool/src/acp/schema/session_updates.py—ToolCallStart/ToolCallProgress定义
AgentPool 源码¶
packages/agentpool/src/agentpool_server/acp_server/event_converter.py— 核心 ACP 事件转换器packages/agentpool/src/agentpool_server/acp_server/session.py— ACP session 管理packages/agentpool/src/agentpool_server/acp_server/session_manager.py— Session 生命周期管理packages/agentpool/src/agentpool_server/acp_server/server.py— ACP server 入口packages/agentpool_toolsets/builtin/subagent_tools.py— 发射 SubAgentEvent 和 SpawnSessionStartpackages/agentpool/src/agentpool/agents/events/events.py— 事件定义:SubAgentEvent(line 614)、SpawnSessionStart(line 646)
相关 RFC¶
- RFC-0013: Subagent Event Stream Unification for OpenCode Protocol
- RFC-0014: SpawnSessionStart Event for Explicit Subsession Creation
- RFC-0025: Shared Agent Architecture
- RFC-0026: Per-Session Agent Isolation