feature/sigit-code-cloud
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 1 | --- |
| 2 | name: agent-client-protocol |
| 3 | description: Implement or debug Agent Client Protocol (ACP) support in Rust for siGit Code. Use when working on ACP JSON-RPC over stdio, the agent-client-protocol crate, session or prompt handlers, streaming notifications, or editor integration. |
| 4 | --- |
| 5 | |
| 6 | # Skill: Agent Client Protocol (ACP) — Rust Implementation |
| 7 | |
| 8 | ## Overview |
| 9 | |
| 10 | ACP is a JSON-RPC 2.0 protocol over **stdio** for integrating AI coding agents |
| 11 | with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess; |
| 12 | the editor is the client. Communication is newline-delimited JSON on stdin/stdout. |
| 13 | |
| 14 | Crate: `agent-client-protocol = "0.10.4"` (latest as of 2025) |
| 15 | Docs: https://docs.rs/agent-client-protocol |
| 16 | Spec: https://agentclientprotocol.com |
| 17 | |
| 18 | --- |
| 19 | |
| 20 | ## Dependency setup |
| 21 | |
| 22 | ```toml |
| 23 | [dependencies] |
| 24 | agent-client-protocol = "0.10.4" |
| 25 | async-trait = "0.1" |
| 26 | tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync"] } |
| 27 | tokio-util = { version = "0.7", features = ["compat"] } |
| 28 | futures = "0.3" |
| 29 | ``` |
| 30 | |
| 31 | --- |
| 32 | |
| 33 | ## The `Agent` trait |
| 34 | |
| 35 | Declared `#[async_trait::async_trait(?Send)]` — futures are `!Send`. |
| 36 | Your impl needs the same annotation: |
| 37 | |
| 38 | ```rust |
| 39 | #[async_trait::async_trait(?Send)] |
| 40 | impl Agent for MyAgent { |
| 41 | async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse> { ... } |
| 42 | async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse> { ... } |
| 43 | async fn new_session(&self, args: NewSessionRequest) -> Result<NewSessionResponse> { ... } |
| 44 | async fn prompt(&self, args: PromptRequest) -> Result<PromptResponse> { ... } |
| 45 | async fn cancel(&self, args: CancelNotification) -> Result<()> { ... } |
| 46 | // All other methods have default impls that return Error::method_not_found() |
| 47 | } |
| 48 | ``` |
| 49 | |
| 50 | You must implement `initialize`, `authenticate`, `new_session`, `prompt`, and `cancel`. |
| 51 | Everything else (`load_session`, `set_session_mode`, etc.) defaults to `Err(method_not_found)`. |
| 52 | |
| 53 | --- |
| 54 | |
| 55 | ## Types and their builders |
| 56 | |
| 57 | All `#[non_exhaustive]` structs require builder methods — struct literal syntax won't compile. |
| 58 | |
| 59 | ### `InitializeRequest` / `InitializeResponse` |
| 60 | |
| 61 | ```rust |
| 62 | // Response builder — use ProtocolVersion::V1, NOT args.protocol_version: |
| 63 | InitializeResponse::new(ProtocolVersion::V1) |
| 64 | .agent_info( |
| 65 | Implementation::new("my-agent", env!("CARGO_PKG_VERSION")) |
| 66 | .title("My Agent"), |
| 67 | ) |
| 68 | .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new( |
| 69 | "my-agent", "My Agent", |
| 70 | ))]) |
| 71 | .agent_capabilities(AgentCapabilities::default()) |
| 72 | ``` |
| 73 | |
| 74 | `auth_methods` must include at least one `AuthMethod::Agent` or Zed hangs on |
| 75 | "Loading…" forever. Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` |
| 76 | from the crate. |
| 77 | |
| 78 | ### `AuthenticateResponse` |
| 79 | |
| 80 | ```rust |
| 81 | Ok(AuthenticateResponse::default()) // No auth = just return default |
| 82 | ``` |
| 83 | |
| 84 | ### `NewSessionResponse` |
| 85 | |
| 86 | ```rust |
| 87 | let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); |
| 88 | Ok(NewSessionResponse::new(session_id)) |
| 89 | ``` |
| 90 | |
| 91 | `SessionId` is a newtype with `Clone`, `PartialEq`, `Display`, `Into<String>`, |
| 92 | and `AsRef<str>`. Store it as-is (not as `String`) so `==` works directly. |
| 93 | |
| 94 | ### `PromptRequest` |
| 95 | |
| 96 | ```rust |
| 97 | args.session_id // type: SessionId |
| 98 | args.prompt // type: Vec<ContentBlock> |
| 99 | ``` |
| 100 | |
| 101 | Extract user text from the prompt: |
| 102 | ```rust |
| 103 | let user_text: String = args.prompt.iter() |
| 104 | .filter_map(|block| match block { |
| 105 | ContentBlock::Text(t) => Some(t.text.as_str()), |
| 106 | _ => None, |
| 107 | }) |
| 108 | .collect::<Vec<_>>() |
| 109 | .join("\n"); |
| 110 | ``` |
| 111 | |
| 112 | ### `PromptResponse` |
| 113 | |
| 114 | ```rust |
| 115 | Ok(PromptResponse::new(StopReason::EndTurn)) |
| 116 | // Other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal |
| 117 | ``` |
| 118 | |
| 119 | ### `ContentBlock` |
| 120 | |
| 121 | ```rust |
| 122 | // Text block — use the From impl: |
| 123 | ContentBlock::from("some text") // impl From<T: Into<String>> for ContentBlock |
| 124 | |
| 125 | // Pattern-match incoming blocks: |
| 126 | match block { |
| 127 | ContentBlock::Text(t) => t.text.as_str(), |
| 128 | ContentBlock::ResourceLink(_) => ..., |
| 129 | ContentBlock::Resource(_) => ..., |
| 130 | _ => ..., // non_exhaustive — always need a wildcard |
| 131 | } |
| 132 | ``` |
| 133 | |
| 134 | ### `ContentChunk` + `SessionUpdate` — streaming |
| 135 | |
| 136 | ```rust |
| 137 | let chunk = ContentChunk::new(ContentBlock::from(delta_text)); |
| 138 | let update = SessionUpdate::AgentMessageChunk(chunk); |
| 139 | // Other variants: UserMessageChunk, AgentThoughtChunk, ToolCall, Plan, ... |
| 140 | ``` |
| 141 | |
| 142 | ### `SessionNotification` — send streaming content to client |
| 143 | |
| 144 | ```rust |
| 145 | let notification = SessionNotification::new(session_id.clone(), update); |
| 146 | // Deliver via AgentSideConnection::session_notification() |
| 147 | ``` |
| 148 | |
| 149 | ### `Error` |
| 150 | |
| 151 | ```rust |
| 152 | // There is NO Error::internal(msg) method — use: |
| 153 | agent_client_protocol::Error::new(-32603, "your message here") |
| 154 | |
| 155 | // For invalid params: |
| 156 | agent_client_protocol::Error::invalid_params() |
| 157 | |
| 158 | // For method not found (already the trait default): |
| 159 | agent_client_protocol::Error::method_not_found() |
| 160 | ``` |
| 161 | |
| 162 | --- |
| 163 | |
| 164 | ## Running the agent — `AgentSideConnection` |
| 165 | |
| 166 | Wraps stdin/stdout with JSON-RPC machinery. |
| 167 | |
| 168 | ```rust |
| 169 | use futures::future::LocalBoxFuture; |
| 170 | use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; |
| 171 | |
| 172 | // Adapt tokio I/O to futures AsyncRead/AsyncWrite (the SDK expects these) |
| 173 | let stdin = tokio::io::stdin().compat(); |
| 174 | let stdout = tokio::io::stdout().compat_write(); |
| 175 | |
| 176 | // Must run inside a LocalSet — the spawn fn takes LocalBoxFuture (!Send) |
| 177 | let local = tokio::task::LocalSet::new(); |
| 178 | local.run_until(async move { |
| 179 | let (conn, io_task) = AgentSideConnection::new( |
| 180 | agent, |
| 181 | stdout, |
| 182 | stdin, |
| 183 | |fut: LocalBoxFuture<'static, ()>| { |
| 184 | tokio::task::spawn_local(fut); // requires LocalSet context |
| 185 | }, |
| 186 | ); |
| 187 | |
| 188 | // ... set up forwarder task using conn ... |
| 189 | |
| 190 | io_task.await // drives JSON-RPC until client disconnects |
| 191 | }).await; |
| 192 | ``` |
| 193 | |
| 194 | `AgentSideConnection::new` returns `(conn, io_task)` — you need both. `io_task` |
| 195 | drives the actual IO; `conn` sends notifications. The spawn closure gets |
| 196 | `LocalBoxFuture<'static, ()>` (not Send), so use `tokio::task::spawn_local`, |
| 197 | not `tokio::spawn`. Everything must sit inside |
| 198 | `tokio::task::LocalSet::new().run_until(...)`. |
| 199 | |
| 200 | --- |
| 201 | |
| 202 | ## Streaming — circular dependency pattern |
| 203 | |
| 204 | `Agent::prompt()` needs to send `SessionNotification` through the connection, |
| 205 | but the connection is built *from* the agent. Break the cycle with an mpsc channel: |
| 206 | |
| 207 | ```rust |
| 208 | // 1. Create channel BEFORE the agent |
| 209 | let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); |
| 210 | |
| 211 | // 2. Pass sender into agent |
| 212 | let agent = MyAgent { notification_tx, ... }; |
| 213 | |
| 214 | // 3. Create connection |
| 215 | let (conn, io_task) = AgentSideConnection::new(agent, stdout, stdin, |fut| { |
| 216 | tokio::task::spawn_local(fut); |
| 217 | }); |
| 218 | |
| 219 | // 4. Spawn forwarder that holds `conn` |
| 220 | tokio::task::spawn_local(async move { |
| 221 | while let Some(notification) = notification_rx.recv().await { |
| 222 | conn.session_notification(notification).await.ok(); |
| 223 | } |
| 224 | }); |
| 225 | |
| 226 | // 5. Run IO |
| 227 | io_task.await; |
| 228 | ``` |
| 229 | |
| 230 | Inside `prompt()`, push chunks through the channel: |
| 231 | ```rust |
| 232 | self.notification_tx.send(SessionNotification::new( |
| 233 | session_id.clone(), |
| 234 | SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(delta))), |
| 235 | )).await.ok(); // ignore send errors (channel closed = client gone) |
| 236 | ``` |
| 237 | |
| 238 | --- |
| 239 | |
| 240 | ## Logging |
| 241 | |
| 242 | Log to **stderr** — stdout is the ACP JSON-RPC wire: |
| 243 | |
| 244 | ```rust |
| 245 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) |
| 246 | .target(env_logger::Target::Stderr) |
| 247 | .init(); |
| 248 | ``` |
| 249 | |
| 250 | --- |
| 251 | |
| 252 | ## Protocol flow |
| 253 | |
| 254 | ``` |
| 255 | Editor Agent |
| 256 | │ │ |
| 257 | │── initialize ────────────────►│ (negotiate version + capabilities) |
| 258 | │◄─ InitializeResponse ─────────│ |
| 259 | │ │ |
| 260 | │── authenticate ──────────────►│ (method_id from authMethods) |
| 261 | │◄─ AuthenticateResponse ───────│ |
| 262 | │ │ |
| 263 | │── session/new ───────────────►│ (create session, load model) |
| 264 | │◄─ NewSessionResponse ─────────│ |
| 265 | │ │ |
| 266 | │── session/prompt ────────────►│ (user message) |
| 267 | │◄─ session/update (N times) ───│ (streaming tokens via notification) |
| 268 | │◄─ PromptResponse ─────────────│ (stop_reason = EndTurn when done) |
| 269 | │ │ |
| 270 | │── session/cancel (optional) ──►│ |
| 271 | │ │ |
| 272 | │── [disconnect] ───────────────►│ (io_task future resolves → shutdown) |
| 273 | ``` |
| 274 | |
| 275 | --- |
| 276 | |
| 277 | ## Zed configuration |
| 278 | |
| 279 | ```json |
| 280 | { |
| 281 | "agent_servers": { |
| 282 | "MyAgent": { |
| 283 | "type": "custom", |
| 284 | "command": "/path/to/binary" |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | ``` |
| 289 | |
| 290 | --- |
| 291 | |
| 292 | ## Gotchas |
| 293 | |
| 294 | 1. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`. |
| 295 | 2. **All protocol structs are `#[non_exhaustive]`** — use builder methods, |
| 296 | never struct literals. Add `_ => ...` wildcards when matching. |
| 297 | 3. **`LocalBoxFuture` is `!Send`** — `tokio::spawn` won't work; use |
| 298 | `tokio::task::spawn_local` inside a `LocalSet`. |
| 299 | 4. **`tokio::task::spawn_local` panics outside a `LocalSet`** — wrap with |
| 300 | `LocalSet::new().run_until(async { ... }).await`. |
| 301 | 5. **Store `SessionId` as `SessionId`**, not `String` — otherwise `==` |
| 302 | comparisons get annoying. |
| 303 | 6. **One session per connection is fine for MVP** — reuse the model with |
| 304 | `clear_history()` instead of reloading. |
| 305 | 7. **`AgentCapabilities::default()` exists** — all capabilities None/false. |
| 306 | 8. **`block_in_place` panics inside `spawn_local`** — dependencies that call |
| 307 | `tokio::task::block_in_place` internally (e.g. `mistralrs`) will blow up |
| 308 | with "can call blocking only when running on the multi-threaded runtime" |
| 309 | from a `spawn_local` task. Fix: do the blocking work *before* entering |
| 310 | the `LocalSet`, while you're still on a normal multi-thread worker, then |
| 311 | pass the result into your agent struct. |
| 312 | 9. **Empty `authMethods` hangs Zed** — `InitializeResponse` with an empty |
| 313 | `auth_methods` vec makes Zed show "Loading…" forever. Always include at |
| 314 | least one `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. |
| 315 | Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` from the crate. |
| 316 | 10. **Never write to stdout except JSON-RPC** — any library that prints to |
| 317 | stdout (`mistralrs` model metadata, stray `println!`, whatever) will |
| 318 | corrupt the wire. Redirect diagnostics to stderr. If a dependency writes |
| 319 | to stdout internally, fix it or suppress it before shipping. |