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