Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

acproxy

acproxy 是一个使用官方 Rust ACP SDK 构建的 ACP Agent CLI demo。它通过 stdin/stdout 与 Zed 等 ACP Client 通信,目前不访问 HTTP 服务,而是直接调用本地 DirectLogic 生成回复。

  • Rust:1.88 或更高版本
  • Rust edition:2024
  • ACP SDK:agent-client-protocol = "2.0.0"
  • ACP 协议:稳定版 v1
  • Transport:stdio JSON-RPC

项目架构

flowchart LR
    Client["ACP Client<br/>Zed / smoke client"]
    Transport["官方 Rust ACP SDK<br/>stdio JSON-RPC"]
    Adapter["AcpAdapter<br/>协议转换与生命周期"]
    State["AgentState<br/>内存 Session Store"]
    Logger["DebugLogger<br/>可选调试日志与 stderr 诊断"]
    Service["ConversationService<br/>业务接口"]
    Logic["DirectLogic<br/>当前演示实现"]

    Client <-->|"ACP request / response / notification"| Transport
    Transport <--> Adapter
    Adapter <--> State
    Adapter --> Logger
    Adapter --> Service
    Service --> Logic
    Adapter -->|"session/update"| Transport
Loading

各层职责:

  • main.rs:解析 serve、smoke 子命令并启动程序。
  • agent.rs:注册 ACP handler、维护 Session 状态、校验请求、处理取消,并把业务回复转换为 session/update。
  • debug_log.rs:可选的线程安全调试日志(含大小上限和轮转),同时承载 stderr 上的 INFO/ERROR 诊断输出,不占用协议 stdout。
  • logic.rs:定义与 ACP SDK 解耦的 ConversationService、请求/响应模型,以及当前 DirectLogic。
  • smoke.rs:启动一个真实子进程,通过 stdio 执行带超时保护的端到端 ACP 验证。
src/
├── main.rs
├── agent.rs
├── debug_log.rs
├── logic.rs
└── smoke.rs

当前支持的 ACP 能力

ACP 方法或能力 状态 当前行为
initialize 支持 本 Agent 只实现 ACP v1,固定响应 v1;由客户端决定是否接受
session/new 支持 创建内存 Session,生成 UUID,保存工作目录和附加目录
session/prompt 支持 接受文本和 Resource Link,调用 ConversationService
session/update 支持 使用 AgentMessageChunk 分段发送文本
session/cancel 支持 取消当前 Prompt、停止业务 future 和分段等待
session/list 支持 支持 cwd 过滤、每页 20 条和 keyset cursor(排序稳定,见下文说明)
session/resume 支持 恢复本进程内已存在的 Session,不回放历史消息
session/close 支持 取消进行中的工作并关闭 Session,但仍保留在 Session List
session/delete 支持 删除 Session;若正在执行 Prompt,也会先取消
additionalDirectories 支持 校验、保存、列出,并传递给业务逻辑
Text Prompt 支持 多个 Text Block 使用换行拼接
Resource Link Prompt 支持 向业务层传递 name、uri 和 mimeType

同一个 Session 同一时间只允许一个活动 Prompt。Session 关闭后不能继续 Prompt,必须先调用 session/resume。只有 session/delete 会将它从 session/list 中移除。

session/list 的 keyset cursor 说明:cursor 由上一页最后一条的 updatedAt 和 Session ID 组成,保证排序稳定(相同数据翻页不会重复或遗漏);但 updatedAt 是可变排序键,翻页 期间被 prompt/close/resume 更新过的 Session 会移动到新的位置,因此分页结果不是快照语义。 对本 demo 而言这是可接受的取舍;如需严格快照,应改用不可变的创建时间或世代号作为游标。

当前未支持

  • session/load:它要求恢复并通过通知回放完整历史;当前没有持久化历史,因此只声明 session/resume。
  • MCP Server:mcpServers 非空时返回明确的参数错误,不会静默忽略。
  • 图片、音频和 Embedded Resource Prompt。
  • Tool Call、终端命令执行和人工权限确认。
  • Session Mode、模型切换和配置选项。
  • 认证能力。
  • 跨进程 Session 恢复。

Session 状态

每个 Session 当前保存:

  • ACP Session ID
  • 工作目录 cwd
  • additionalDirectories
  • 业务侧 conversation_id
  • 根据第一次 Prompt 生成的标题
  • 最近更新时间
  • 当前 Prompt 的取消令牌
  • 打开或关闭状态

状态存放在进程内的 HashMap 中,程序退出后全部清空。conversation_id 用于以后映射真实 对话接口中的 Conversation ID。

输入校验规则:

  • cwd 必须是绝对路径且已存在的目录。
  • additionalDirectories 中的每个路径必须是绝对路径。
  • 路径按 components 存储时会去除末尾多余的分隔符,session/list 返回规范化的 cwd。
  • 当前不支持非空 mcpServers。
  • prompt、resume、close 遇到未知 Session 时返回 resource_not_found; delete 采用幂等语义。
  • 已关闭 Session 或同 Session 中重复提交 Prompt 返回协议错误。

Prompt 与流式输出

当前 ConversationService 先生成完整回复,ACP adapter 再模拟流式输出:

  1. 将回复按 48 个 Unicode 字符拆分。
  2. 每段发送一条 session/update。
  3. 相邻分段等待 40ms(约 1200 字符/秒,长回复也不会显得卡住)。
  4. 最后用 session/prompt response 返回 end_turn。

示意:

session/prompt(一条 120 字符的回复)
    │
    ├── session/update: "……第 1 段(48 字符)"
    ├── 等待 40ms
    ├── session/update: "……第 2 段(48 字符)"
    ├── 等待 40ms
    ├── session/update: "……第 3 段(24 字符)"
    └── PromptResponse: stopReason = "end_turn"

业务调用和分段等待都同时监听 CancellationToken。收到 session/cancel、 session/close 或 session/delete 后,会丢弃正在等待的业务 future,并返回 stopReason = "cancelled"。Prompt 状态使用对应的 token 做身份校验,旧 Prompt 的异步收尾 不会清除新 Prompt 的状态。

构建与运行

cd /Users/simon/dev/acproxy

cargo build
cargo test
cargo fmt --check
cargo clippy --all-targets -- -D warnings

启动 ACP Server:

cargo run -- serve

默认不写任何日志文件,只在 stderr 输出少量 INFO/ERROR 生命周期消息。需要调试日志时 显式传入 --log-file:

cargo run -- serve --log-file /tmp/acproxy-debug.log

--quiet(-q)可以静音 stderr 上的 INFO 消息(ERROR 仍会输出):

cargo run -- serve --quiet

serve 是默认子命令,下面的命令效果相同:

cargo run

Release 构建:

cargo build --release
/Users/simon/dev/acproxy/target/release/acproxy serve \
  --log-file /Users/simon/dev/acproxy/acproxy-debug.log

ACP 协议独占 stdout。所有日志必须写入 stderr 或日志文件,不能使用 stdout 打印普通日志,否则 会破坏 JSON-RPC 数据流。

Debug 日志

Debug 日志默认关闭。只有显式传入 --log-file <PATH> 时才会开启,因此不会在被编辑器启动时 向用户的工作目录意外写入日志文件。

开启后 Server 会记录:

  • 所有进入 typed handler 的 ACP request。
  • 成功 response 和 error response。
  • 入站 session/cancel notification。
  • 出站 session/update notification,包括每个文本分段。
  • INFO/ERROR 生命周期事件(创建 Session、业务失败等)。

日志为单行追加格式并在每条记录后立即 flush:

2026-07-25T10:30:00.123Z DEBUG REQUEST method=session/prompt id=Number(3) payload=...
2026-07-25T10:30:00.124Z DEBUG NOTIFICATION_OUT method=session/update id=- payload=...
2026-07-25T10:30:00.488Z DEBUG RESPONSE method=session/prompt id=Number(3) payload=...

轮转与保留:

  • 单个日志文件超过 8 MiB 时自动轮转:现有文件改名为 <path>.1(只保留一代),随后新建 继续写入。
  • 打开一个已超限的旧日志文件时也会先轮转再追加。
  • 轮转失败(例如只读文件系统)时降级为原地截断,保证日志功能可用。
  • 父目录不存在时会自动创建;正常路径下追加写入,不会覆盖已有内容。

Prompt 内容、文件 URI 和工作目录可能出现在 Debug 日志中,因此生产环境应:

  • 将日志写入访问受控的目录。
  • 不在日志中记录 Token 等认证信息。
  • 不需要调试时不传 --log-file 即完全关闭;需要保留长期历史时自行归档 .1 文件。
  • stderr 上的 INFO 消息可用 --quiet 静音。

日志记录位于 typed ACP handler 边界。无法解析的 JSON 或没有注册 handler 的未知方法不会进入 当前 Debug 日志。

使用 smoke client 提问

所有普通问题都会触发分段输出:

cargo run -- smoke "请介绍一下 ACP 协议"

当前 DirectLogic 还提供三个演示命令:

cargo run -- smoke /help
cargo run -- smoke /about
cargo run -- smoke /error
  • /help:显示演示命令。
  • /about:显示 Session、Conversation、工作目录和资源信息。
  • /error:模拟业务错误,用于验证 ACP error response。

smoke client 实际执行以下生命周期:

initialize
  → session/new
  → session/list
  → session/prompt(text block + resource_link block)+ session/update
  → session/close
  → session/list
  → session/resume
  → session/delete

Prompt 中始终附带一个 resource_link block,因此每次 smoke 运行都会端到端验证 Resource Link 的 wire 解析;/about 的输出会显示这个资源。

Zed 配置

先构建 Release 版本:

cargo build --release

在 Zed 中配置自定义 Agent:

{
  "agent_servers": {
    "acproxy": {
      "type": "custom",
      "command": "/Users/simon/dev/acproxy/target/release/acproxy",
      "args": [
        "serve",
        "--log-file",
        "/Users/simon/dev/acproxy/acproxy-debug.log"
      ],
      "env": {
        "RUST_BACKTRACE": "1"
      }
    }
  }
}

serve 用于启动 ACP Server,--log-file 指定 Debug 日志路径(不传则不产生日志文件, 也不会写入被编辑项目的工作目录)。对应命令为:

/Users/simon/dev/acproxy/target/release/acproxy serve \
  --log-file /Users/simon/dev/acproxy/acproxy-debug.log

字段说明:

  • "type": "custom":注册自定义 External Agent。
  • "command":acproxy Release 可执行文件的绝对路径。
  • "args":依次传入 serve 和日志文件参数。
  • "env":传递给 Agent 进程的环境变量;RUST_BACKTRACE=1 用于在 panic 时输出堆栈, 不需要时可以删除。

如果 settings.json 已经包含主题、快捷键或其他 Agent 配置,只合并 agent_servers.acproxy 这一项,不要覆盖整个文件。

配置完成后:

  1. 重启 Zed 或重新加载配置。
  2. 打开 Agent Panel。
  3. 创建新会话。
  4. 从 External Agents 中选择 acproxy。
  5. 输入任意问题,客户端将接收分段 session/update。

实时查看 acproxy 自身的请求、响应和通知日志:

tail -f /Users/simon/dev/acproxy/acproxy-debug.log

如需查看 Zed 侧的完整 ACP 通信日志,在 Command Palette 中运行:

dev: open acp logs

如何实现真实业务逻辑

业务扩展点是 logic.rs 中的 ConversationService:

use std::future::Future;

pub(crate) trait ConversationService: Clone + Send + Sync + 'static {
    fn chat(
        &self,
        request: ChatRequest,
        cancel: &CancellationToken,
    ) -> impl Future<Output = ConversationOutcome> + Send;
}

ChatRequest 提供:

pub(crate) struct ChatRequest {
    pub acp_session_id: String,
    pub conversation_id: Option<String>,
    pub message: String,
    pub cwd: PathBuf,
    pub additional_directories: Vec<PathBuf>,
    pub resources: Vec<ChatResource>,
}

业务实现返回以下结果之一:

  • ConversationOutcome::Completed(ChatCompletion):成功完成。
  • ConversationOutcome::Cancelled:主动取消。
  • ConversationOutcome::Failed(String):业务失败;详细错误写 stderr,客户端收到通用错误。

1. 新增业务实现

可以在 logic.rs 中新增实现:

#[derive(Clone)]
pub(crate) struct MyConversationService {
    // 后续可放 HTTP client、配置或认证信息
}

impl ConversationService for MyConversationService {
    async fn chat(
        &self,
        request: ChatRequest,
        cancel: &CancellationToken,
    ) -> ConversationOutcome {
        if cancel.is_cancelled() {
            return ConversationOutcome::Cancelled;
        }

        // 在这里调用真实对话逻辑。
        let content = format!("业务回复:{}", request.message);

        ConversationOutcome::Completed(ChatCompletion {
            conversation_id: request
                .conversation_id
                .or_else(|| Some(format!("remote-{}", request.acp_session_id))),
            content,
        })
    }
}

trait 使用 Rust 原生的 return-position impl Future 明确保证 future 为 Send,实现端仍可直接 使用 async fn,不需要引入 async-trait。

2. 注入业务实现

agent.rs 提供泛型启动入口:

use std::path::Path;

agent::serve_with(
    MyConversationService { /* config */ },
    Some(Path::new("/path/to/acproxy-debug.log")),
    false, // quiet
).await

例如在 main.rs 的 Serve 分支中替换:

Command::Serve { log_file, quiet } => {
    let service = logic::MyConversationService { /* config */ };
    agent::serve_with(service, log_file.as_deref(), quiet)
        .await
        .map_err(Into::into)
}

ACP handler、Session 生命周期、取消和 session/update 转换都不需要修改。

3. 后续接入 HTTP 对话接口

HTTP 实现通常需要完成以下映射:

ChatRequest.acp_session_id       → 本地链路追踪或请求 ID
ChatRequest.conversation_id      → HTTP 接口的 Conversation ID
ChatRequest.message              → 用户消息
ChatRequest.cwd                  → 当前工作目录
ChatRequest.additional_directories → 其他工作区根目录
ChatRequest.resources            → Prompt 中引用的资源
HTTP response.conversation_id    → ChatCompletion.conversation_id
HTTP response.content            → ChatCompletion.content

建议把 HTTP client 保存在 Service struct 中并复用连接,不要每次 Prompt 都创建新 client。认证 Token 应从环境变量或安全存储读取,不要放在命令行参数中。

ACP adapter 使用 tokio::select! 监听取消;取消时会 drop 正在等待的 chat future。业务实现如果 还启动了后台任务,也应监听 CancellationToken 并主动停止这些任务。

4. 接入真正的上游流式响应

当前 ConversationService 返回完整 content,再由 ACP 层模拟分段。如果真实接口是 SSE、 NDJSON 或其他流式协议,建议下一步把业务接口扩展为事件流或 channel:

HTTP/SSE chunk
    → ConversationService event
    → SessionUpdate::AgentMessageChunk
    → ACP Client

届时应直接转发上游 chunk,并移除 demo 中的 48 字符拆分和 40ms sleep,避免二次缓冲。

测试

当前测试覆盖:

  • Text 与 Resource Link 解析
  • Unicode 分段
  • 非法 cursor
  • 路径和 MCP 参数校验(含 cwd 存在性)
  • 路径尾部分隔符规范化
  • Debug 日志的关闭态与轮转
  • Prompt 取消
  • 旧 Prompt/新 Prompt 并发状态隔离
  • 业务成功、取消和失败
  • 完整 stdio ACP 生命周期 smoke(text + resource_link prompt、30s 响应超时和 10s 退出超时)

运行:

cargo test
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo run -- smoke "端到端验证"

CI(.github/workflows/rust.yml)固定使用 stable 工具链并执行相同的检查:fmt --check、 clippy -D warnings、build、test 和端到端 smoke,同时启用 cargo 构建缓存。

主要依赖

  • agent-client-protocol:官方 ACP Rust SDK。
  • tokio:异步运行时、stdio、子进程和定时器。
  • tokio-util:CancellationToken。
  • clap:CLI 参数和子命令。
  • chrono:Session RFC 3339 时间戳。
  • uuid:ACP Session ID。
  • serde_json:smoke client JSON-RPC 和 Session cursor。
  • anyhow:CLI 与 smoke client 错误处理。

About

acproxy is a Rust-based ACP Agent CLI demo that implements the official Agent Client Protocol SDK. It communicates with ACP clients (like Zed) via stdio/JSON-RPC and provides extensible business logic through a pluggable ConversationService trait for building real agent integrations.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages