Files
qiming/qiming-rcoder/specs/agent-resume-acp/agent-resume-acp-spec.md
2026-06-01 13:54:52 +08:00

26 KiB
Raw Blame History

Agent Resume via ACP 设计方案

1. 背景

1.1 需求描述

通过 ACP 协议与 Agent 对话时,如果 Agent 停止后,希望能通过 resume 参数继续之前的上下文对话记录,与 Agent 继续对话。

1.2 相关资源

  • claude-code-acp: Zed 公司对 Claude Code 的 ACP 协议封装(参考:tmp/claude-code-acp
  • rust-sdk: ACP 协议的 Rust SDK参考tmp/rust-sdk
  • 当前实现: crates/agent_abstraction/src/compat/claude_code_launcher.rs

2. 技术分析

2.1 ACP 协议中的会话恢复方式

ACP 协议定义了多种会话管理方法:

方法 功能 状态
session/new (NewSessionRequest) 创建新会话 稳定
session/load (LoadSessionRequest) 加载已存在的会话并重放历史 稳定,但 claude-code-acp 未实现
session/resume (ResumeSessionRequest) 恢复会话(不重放历史) unstable需要 unstable_session_resume feature
session/fork (ForkSessionRequest) 分叉会话 unstable

2.2 claude-code-acp 的 Resume 实现机制

通过分析 tmp/claude-code-acp/src/acp-agent.ts,发现 claude-code-acp 支持三种 resume 方式:

方式一:通过 NewSessionRequest 的 meta 参数

// acp-agent.ts:205-216
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
  return await this.createSession(params, {
    resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
  });
}

传参结构

{
  "cwd": "/path/to/project",
  "_meta": {
    "claudeCode": {
      "options": {
        "resume": "previous-session-id"
      }
    }
  }
}

方式二:通过 unstable_resumeSession 方法

// acp-agent.ts:233-246
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
  return await this.createSession(
    { cwd: params.cwd, mcpServers: params.mcpServers ?? [], _meta: params._meta },
    { resume: params.sessionId }
  );
}

方式三:通过 load_sessionclaude-code-acp 不支持)

// 返回 Error::method_not_found()

2.3 createSession 内部 Resume 处理逻辑

// acp-agent.ts:593-709
private async createSession(
  params: NewSessionRequest,
  creationOpts: { resume?: string; forkSession?: boolean } = {},
): Promise<NewSessionResponse> {
  // 1. 确定 sessionId
  let sessionId;
  if (creationOpts.forkSession) {
    sessionId = randomUUID();
  } else if (creationOpts.resume) {
    sessionId = creationOpts.resume;  // 👈 使用传入的 session_id
  } else {
    sessionId = randomUUID();
  }

  // 2. 构建 extraArgs
  const extraArgs = { ...userProvidedOptions?.extraArgs };
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
    extraArgs["session-id"] = sessionId;  // 👈 新会话才设置 session-id
  }

  // 3. 构建 Options 传递给 SDK
  const options: Options = {
    // ... 其他配置
    extraArgs,
    ...creationOpts,  // 👈 包含 { resume: sessionId }
  };

  // 4. 调用 SDK 的 query 函数
  const q = query({ prompt: input, options });
}

关键发现

  1. resume 参数最终通过 Options 传递给 @anthropic-ai/claude-agent-sdkquery() 函数
  2. SDK 会根据 Options.resume 自动恢复之前的会话上下文
  3. 当 resume 有值时,不设置新的 session-id extraArg

2.4 当前项目的实现分析

当前流程 (claude_code_launcher.rs:438-480)

let session_id = match session_id_for_closure {
    Some(sid) => {
        // 尝试 load_session
        match client_conn.load_session(LoadSessionRequest::new(...)).await {
            Ok(resp) => given_session_id,
            Err(e) => {
                // 失败时回退到 new_session
                // resume_session_id 通过 meta.claudeCode.options.resume 传递
                let new_session_request = NewSessionRequest::new(...)
                    .mcp_servers(mcp_servers)
                    .meta(system_prompt_meta);  // 👈 包含 resume
                client_conn.new_session(new_session_request).await?
            }
        }
    }
    None => {
        // 创建新会话
        client_conn.new_session(NewSessionRequest::new(...)).await?
    }
};

问题分析

  1. 冗余逻辑:先尝试 load_session(必定失败),然后回退到 new_session
  2. 混淆概念session_id 参数和 resume_session_id 参数的关系不清晰
  3. 缺少 ACP 原生 resume:未使用 ResumeSessionRequest

3. 设计方案

3.1 方案选择

推荐使用 方式一:通过 NewSessionRequest 的 meta 参数 实现 resume

理由

  1. 稳定性:不依赖 unstable feature
  2. 兼容性:适用于所有支持 ACP 的 Agent
  3. 当前实现已支持AgentStartConfig.build_meta() 已经正确构建了 claudeCode.options.resume 结构

3.2 核心改动

3.2.1 简化 claude_code_launcher.rs 的会话创建逻辑

改动位置crates/agent_abstraction/src/compat/claude_code_launcher.rs:438-480

改动前

let session_id = match session_id_for_closure {
    Some(sid) => {
        // 先尝试 load_session失败后再 new_session
        // ...
    }
    None => {
        // new_session
    }
};

改动后

// 统一使用 new_sessionresume 通过 meta 传递
let new_session_request = NewSessionRequest::new(project_path_for_closure.clone())
    .mcp_servers(mcp_servers)
    .meta(system_prompt_meta);  // 已包含 claudeCode.options.resume

let session_id = client_conn
    .new_session(new_session_request)
    .await?
    .session_id;

3.2.2 参数语义调整

参数 原语义 新语义
session_id: Option<String> (launch 方法) 传入则尝试 load_session 移除,不再使用
AgentStartConfig.resume_session_id 通过 meta 传递 保持,作为唯一的 resume 参数来源

3.2.3 数据流示意图

┌──────────────────────────────────────────────────────────────────────────┐
│                           RCoder / Agent Runner                           │
└──────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  ChatPrompt                                                               │
│  ├─ session_id: "abc-123"  (用于标识当前对话,可能是新的或历史的)          │
│  └─ ...                                                                   │
└──────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  acp_worker.rs                                                            │
│  ├─ 检查会话是否需要 resume                                                │
│  │   └─ 如果 session_id 匹配已存在的会话 → 设置 resume_session_id          │
│  └─ 构建 AgentStartConfig                                                 │
│      └─ resume_session_id: Some("abc-123")                                │
└──────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  session_manager.rs                                                       │
│  └─ 调用 ClaudeCodeLauncher::launch()                                     │
│      └─ start_config: AgentStartConfig { resume_session_id, ... }         │
└──────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  claude_code_launcher.rs                                                  │
│  ├─ start_config.build_meta() 构建:                                       │
│  │   {                                                                    │
│  │     "systemPrompt": { "append": "..." },                               │
│  │     "claudeCode": {                                                    │
│  │       "options": {                                                     │
│  │         "resume": "abc-123"  ← resume_session_id                       │
│  │       }                                                                │
│  │     }                                                                  │
│  │   }                                                                    │
│  └─ NewSessionRequest::new(...).meta(meta)                                │
└──────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼ ACP 协议 (session/new)
┌──────────────────────────────────────────────────────────────────────────┐
│  claude-code-acp (Agent)                                                  │
│  ├─ newSession() 解析 _meta.claudeCode.options.resume                     │
│  ├─ createSession(params, { resume: "abc-123" })                          │
│  └─ query({ options: { resume: "abc-123", ... } })                        │
└──────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  @anthropic-ai/claude-agent-sdk                                           │
│  └─ 根据 Options.resume 恢复之前的会话上下文                               │
└──────────────────────────────────────────────────────────────────────────┘

3.3 详细代码改动

文件 1: crates/agent_abstraction/src/compat/claude_code_launcher.rs

// 改动 launch 方法签名,移除 session_id 参数
pub async fn launch(
    &self,
    project_id: String,
    project_path: PathBuf,
    // session_id: Option<String>,  // 👈 移除此参数
    model_provider: Option<ModelProviderConfig>,
    start_config: AgentStartConfig,
    client: C,
) -> Result<LauncherConnectionInfoComplete> {
// 简化会话创建逻辑(约 438-480 行)
// 改动前:
// let session_id = match session_id_for_closure { ... }

// 改动后:
debug!("创建 ACP 会话[new_session]");
let new_session_request = NewSessionRequest::new(project_path_for_closure.clone())
    .mcp_servers(mcp_servers)
    .meta(system_prompt_meta);  // 已包含 resume 信息

let session_id = client_conn
    .new_session(new_session_request)
    .await
    .context("ACP 会话创建失败")?
    .session_id;

debug!("ACP 会话创建成功[new_session], session_id={}", session_id.0);

文件 2: crates/agent_abstraction/src/session/session_manager.rs

// 更新 launch 调用,移除 session_id 参数
let result = launcher
    .launch(
        project_id.clone(),
        project_path.clone(),
        // None,  // 👈 移除 session_id 参数
        model_provider.clone(),
        start_config.clone(),
        acp_client,
    )
    .await;

3.4 Resume 失败降级机制

3.4.1 问题场景

当传入的 session_id 对应的会话不存在时例如会话历史已过期、被清理、或从未存在Agent 会启动失败并抛出错误。

典型错误信息

No conversation found for session id: abc-123

exited with code 1

3.4.2 当前降级实现

降级逻辑已在 session_manager.rs:217-261 实现:

// session_manager.rs
let connection_info = match result {
    Ok(info) => info,
    Err(e) => {
        let error_msg = format!("{:?}", e);

        // 检查是否因为 resume 导致的失败
        if has_resume
            && (error_msg.contains("No conversation found")
                || error_msg.contains("session")
                || error_msg.contains("exited with code 1"))
        {
            tracing::warn!(
                "⚠️ Agent 启动失败(可能因 --resume重试不使用 --resume: error={}",
                error_msg
            );

            // 创建新的 config不包含 resume_session_id
            let retry_config = AgentStartConfig {
                system_prompt: start_config.system_prompt,
                mcp_servers: start_config.mcp_servers,
                extra_meta: start_config.extra_meta,
                service_type: start_config.service_type,
                resume_session_id: None, // ✅ 去掉 resume
            };

            tracing::info!("🔄 重试启动 Agent不使用 --resume");

            // 重试启动
            launcher.launch(..., retry_config, ...).await?
        } else {
            // 其他错误,直接返回
            return Err(e);
        }
    }
};

3.4.3 降级流程图

┌─────────────────────────────────────────────────────────────────┐
│  ChatRequest { session_id: "abc-123" }                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  session_manager.rs                                             │
│  └─ 第一次尝试start_config.resume_session_id = Some("abc-123")│
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  claude_code_launcher.launch()                                  │
│  └─ NewSessionRequest + meta { claudeCode.options.resume }      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼ ACP 协议
┌─────────────────────────────────────────────────────────────────┐
│  claude-code-acp                                                │
│  └─ SDK query({ options: { resume: "abc-123" } })               │
└─────────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              │                               │
              ▼                               ▼
     ┌────────────────┐              ┌────────────────────────┐
     │ 会话存在       │              │ 会话不存在             │
     │ → 恢复上下文   │              │ → 抛出错误             │
     │ → 返回成功     │              │ "No conversation found"│
     └────────────────┘              └────────────────────────┘
                                              │
                                              ▼
                              ┌─────────────────────────────────────────┐
                              │  session_manager.rs 降级处理            │
                              │  ├─ 检测错误关键字                       │
                              │  ├─ 创建新 config: resume_session_id=None│
                              │  └─ 第二次尝试:不带 resume              │
                              └─────────────────────────────────────────┘
                                              │
                                              ▼
                              ┌─────────────────────────────────────────┐
                              │  claude_code_launcher.launch()          │
                              │  └─ NewSessionRequest (无 resume)       │
                              └─────────────────────────────────────────┘
                                              │
                                              ▼
                              ┌─────────────────────────────────────────┐
                              │  成功创建新会话                          │
                              │  └─ 返回新的 session_id                  │
                              └─────────────────────────────────────────┘

3.4.4 错误检测关键字

当前实现检测以下关键字来判断是否因 resume 失败:

关键字 来源 说明
"No conversation found" SDK 错误 明确表示会话不存在
"session" 通用匹配 捕获其他 session 相关错误
"exited with code 1" 进程退出 Agent 进程异常退出

建议改进:可以增加更精确的错误码匹配,例如:

  • "ENOENT" - 会话文件不存在
  • "invalid session" - 无效会话

3.4.5 降级策略配置(可选扩展)

未来可以考虑增加降级策略配置:

pub struct ResumePolicy {
    /// 是否启用降级
    pub enable_fallback: bool,
    /// 最大重试次数
    pub max_retries: u32,
    /// 降级时是否保留部分配置(如 MCP 服务器)
    pub preserve_mcp_servers: bool,
}

3.5 备选方案:使用 ResumeSessionRequest (unstable)

如果需要使用 ACP 原生的 resume 方法,需要:

  1. 启用 unstable feature
# Cargo.toml
agent-client-protocol = { version = "0.6", features = ["unstable_session_resume"] }
  1. 添加 resume_session 调用
#[cfg(feature = "unstable_session_resume")]
async fn resume_session(&self, session_id: SessionId, cwd: PathBuf) -> Result<SessionId> {
    let request = ResumeSessionRequest::new(session_id.clone(), cwd);
    let response = self.client_conn.resume_session(request).await?;
    Ok(response.session_id)
}

不推荐此方案

  • 依赖 unstable API可能随时变更
  • claude-code-acp 的 unstable_resumeSession 内部也是通过 createSession 处理,效果相同

4. API 变更

4.1 内部 API 变更

组件 改动
ClaudeCodeLauncher::launch() 移除 session_id 参数
SessionManager 调用 launch 时不再传递 session_id

4.2 外部 API 无变更

gRPC ChatRequest 和 HTTP API 保持不变,session_id 字段继续用于:

  1. 标识会话(用于进度订阅、取消等)
  2. 决定是否需要 resumeacp_worker.rs 中判断)

5. 测试计划

5.1 单元测试

  • AgentStartConfig.build_meta() 正确构建 resume 结构
  • resume_session_id 为 None 时meta 不包含 claudeCode 字段

5.2 集成测试

  • 新建会话:不传 session_id正常创建
  • Resume 会话:传入历史 session_id能够继续对话
  • Resume 失败降级:传入无效 session_id能够降级为新会话

5.3 降级场景测试

场景 1会话不存在

# 传入一个不存在的 session_id
curl -X POST /chat -d '{
  "project_id": "proj1",
  "session_id": "non-existent-session-id",
  "prompt": "Hello"
}'

# 预期:
# 1. 第一次尝试失败,日志显示 "No conversation found"
# 2. 自动降级,去掉 resume 重试
# 3. 成功创建新会话,返回新的 session_id

场景 2会话已过期

# 使用一个曾经存在但已过期/清理的 session_id
curl -X POST /chat -d '{
  "project_id": "proj1",
  "session_id": "expired-session-id",
  "prompt": "继续之前的对话"
}'

# 预期:同场景 1自动降级为新会话

场景 3会话存在且有效

# 第一次对话
curl -X POST /chat -d '{"project_id":"proj1", "prompt":"记住数字 42"}'
# 返回 session_id: "valid-session-123"

# 第二次对话resume
curl -X POST /chat -d '{
  "project_id": "proj1",
  "session_id": "valid-session-123",
  "prompt": "我之前说的数字是多少?"
}'

# 预期Agent 回答 "42"(证明上下文已恢复)

5.4 手动测试场景

# 1. 首次对话
curl -X POST /chat -d '{"project_id":"proj1", "prompt":"Hello"}'
# 返回 session_id: "sess-abc-123"

# 2. 继续对话resume
curl -X POST /chat -d '{"project_id":"proj1", "session_id":"sess-abc-123", "prompt":"继续刚才的话题"}'
# Agent 应该能够记住之前的上下文

6. 风险评估

风险 影响 缓解措施
SDK 不支持 resume Agent 无法恢复上下文 当前 claude-code-acp 已支持
session_id 过期/无效 resume 失败 已有降级逻辑,自动创建新会话
协议变更 resume 字段位置变化 跟踪 claude-code-acp 更新

7. 实现步骤

  1. Phase 1:简化 claude_code_launcher.rs

    • 移除 load_session 尝试逻辑
    • 统一使用 new_session + meta
  2. Phase 2:清理接口

    • 移除 launch 方法的 session_id 参数
    • 更新所有调用方
  3. Phase 3:测试验证

    • 添加单元测试
    • 执行集成测试

8. 附录

8.1 相关代码位置

文件 行号 描述
crates/agent_abstraction/src/compat/claude_code_launcher.rs 291-573 launch 方法
crates/agent_abstraction/src/traits/agent.rs 95-142 AgentStartConfig.build_meta()
crates/agent_abstraction/src/session/acp_worker.rs 164-167 resume 判断逻辑
crates/agent_abstraction/src/session/session_manager.rs 200-260 launcher 调用
crates/agent_abstraction/src/session/session_manager.rs 217-261 Resume 失败降级逻辑
tmp/claude-code-acp/src/acp-agent.ts 205-246, 593-709 claude-code-acp resume 实现

8.2 参考资料