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.