protocol_droid/adapters/secondary/acp/agent.rs
1//! `AcpAgentPeer` — the agent-side transport for ACP.
2//!
3//! The agent process receives JSON-RPC 2.0
4//! requests from the client on its stdin and
5//! sends replies (and notifications) on its
6//! stdout. The peer is the inverse of
7//! [`AcpStdioPeer`](super::peer::AcpStdioPeer): the
8//! client peer writes to the agent's stdin
9//! and reads from the agent's stdout; the agent
10//! peer reads from its own stdin and writes to
11//! its own stdout.
12//!
13//! # The wire
14//!
15//! Same shape as the client side: newline-
16//! delimited JSON-RPC 2.0. A request is
17//! `{ "jsonrpc": "2.0", "id": N, "method": ...,
18//! "params": ... }`; a reply is `{ "jsonrpc":
19//! "2.0", "id": N, "result": ... }` or
20//! `{ "jsonrpc": "2.0", "id": N, "error": {...} }`.
21//! A notification is a request without an `id`.
22//!
23//! # The peer
24//!
25//! Like the client peer, the agent peer is two
26//! halves:
27//!
28//! - A **reader task**, spawned at construction,
29//! that owns the agent's stdin and routes
30//! incoming envelopes to the application
31//! via an mpsc channel.
32//! - A **writer task**, also spawned at
33//! construction, that owns the agent's
34//! stdout. The application hands a
35//! serialised envelope to the peer; the
36//! peer hands the bytes to the writer.
37//!
38//! Splitting the two halves means the
39//! application can hold a `&AcpAgentPeer` and
40//! call `send_reply` from any task without
41//! re-entering the same lock that the reader
42//! is holding.
43//!
44//! # Why a generic `from_stdio` and not a
45//! `spawn` builder
46//!
47//! The client peer owns the lifecycle of the
48//! child process — it spawns the agent and
49//! kills it on drop. The agent peer is
50//! the other half: the agent process is
51//! already running (it is the process the
52//! client spawned), and the agent peer's job
53//! is to talk to its own stdin / stdout. A
54//! `spawn` builder would be a synonym for
55//! "wire the peer's halves to my own pipes";
56//! a generic `from_stdio` is the seam a test
57//! (or a server that embeds the peer) uses to
58//! hand in any `AsyncRead + AsyncWrite` pair.
59
60use serde::Serialize;
61use serde_json::Value;
62use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
63use tokio::sync::mpsc;
64
65use super::error::AcpPeerError;
66
67/// A JSON-RPC 2.0 request the agent receives
68/// from the client.
69///
70/// The wire form is `{ jsonrpc, id, method,
71/// params }`. The peer decodes the envelope
72/// and hands the application the typed shape;
73/// the application decodes the `params` into
74/// the concrete request type the method
75/// expects (`InitializeRequest`,
76/// `NewSessionRequest`, etc.).
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct AgentRequest {
79 /// The JSON-RPC 2.0 `id`. The application
80 /// passes it back unchanged in the reply
81 /// envelope so the client can correlate
82 /// the reply with the request. The id is
83 /// a `serde_json::Value` because the wire
84 /// spec allows string, number, or null;
85 /// the agent peer passes it through
86 /// verbatim.
87 pub id: Value,
88 /// The method name. The schema's method
89 /// names are dotted slugs
90 /// (`session/new`, `session/prompt`); the
91 /// application routes by this string.
92 pub method: String,
93 /// The raw params. The application decodes
94 /// into the typed request for the
95 /// method; the peer does not attempt
96 /// any routing.
97 pub params: Value,
98}
99
100/// The agent-side transport for ACP.
101///
102/// Constructed via [`AcpAgentPeer::from_stdio`].
103/// The peer owns two tasks (the reader and
104/// the writer); dropping the peer stops the
105/// tasks. The agent process's own lifecycle
106/// is independent — the agent process is
107/// not owned by this peer.
108pub struct AcpAgentPeer {
109 /// The reader task's output. The
110 /// application awaits the next
111 /// [`AgentRequest`] with
112 /// [`recv_request`][Self::recv_request].
113 request_rx: mpsc::Receiver<AgentRequest>,
114 /// The writer task's input. The
115 /// application hands a fully-serialised
116 /// envelope to the channel; the writer
117 /// writes the bytes.
118 write_tx: mpsc::Sender<Vec<u8>>,
119}
120
121impl std::fmt::Debug for AcpAgentPeer {
122 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 // The `mpsc::Sender` does not
124 // implement `Debug`; format the
125 // peer by hand, omitting the
126 // channels.
127 formatter
128 .debug_struct("AcpAgentPeer")
129 .finish_non_exhaustive()
130 }
131}
132
133impl AcpAgentPeer {
134 /// Construct an agent peer from a stdin
135 /// reader and a stdout writer. The
136 /// generic parameters let the test suite
137 /// pass `tokio::io::DuplexStream` halves
138 /// instead of a real `ChildStdin` /
139 /// `ChildStdout`.
140 ///
141 /// The peer spawns a reader task and a
142 /// writer task; the application
143 /// communicates with the peer through
144 /// the mpsc channels the reader and
145 /// writer tasks own. The half moves
146 /// happen at construction time; the
147 /// returned peer is `Send + 'static`.
148 pub fn from_stdio<R, W>(stdin: R, stdout: W) -> Self
149 where
150 R: tokio::io::AsyncRead + Unpin + Send + 'static,
151 W: tokio::io::AsyncWrite + Unpin + Send + 'static,
152 {
153 let (request_tx, request_rx) = mpsc::channel::<AgentRequest>(32);
154 let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>(32);
155
156 // The reader task. Owns the
157 // agent's stdin. Reads one line
158 // at a time, parses, and pushes
159 // the request to the application
160 // channel. The line is silently
161 // dropped on parse error (a
162 // misbehaving client cannot
163 // crash the agent's main loop).
164 tokio::spawn(spawn_reader(BufReader::new(stdin), request_tx));
165
166 // The writer task. Owns the
167 // agent's stdout. Receives
168 // pre-serialised bytes and
169 // writes them with a trailing
170 // `\n`.
171 tokio::spawn(spawn_writer(stdout, write_rx));
172
173 Self {
174 request_rx,
175 write_tx,
176 }
177 }
178
179 /// Await the next [`AgentRequest`] the
180 /// client sent.
181 ///
182 /// Returns `None` when the reader task
183 /// is gone (the client closed its end
184 /// of the wire, or the pipe broke).
185 /// The application is expected to
186 /// shut down its handler loop on
187 /// `None`.
188 pub async fn recv_request(&mut self) -> Option<AgentRequest> {
189 self.request_rx.recv().await
190 }
191
192 /// Send a success reply for a previously
193 /// received request.
194 ///
195 /// The wire form is
196 /// `{ "jsonrpc": "2.0", "id": <id>,
197 /// "result": <result> }`. The `id` is
198 /// the value the request carried; the
199 /// peer passes it through unchanged.
200 pub async fn send_reply(&self, id: Value, result: Value) -> Result<(), AcpPeerError> {
201 let envelope = serde_json::json!({
202 "jsonrpc": "2.0",
203 "id": id,
204 "result": result,
205 });
206 let bytes = serde_json::to_vec(&envelope).map_err(AcpPeerError::Decode)?;
207 let mut framed = bytes;
208 framed.push(b'\n');
209 self.write_tx
210 .send(framed)
211 .await
212 .map_err(|_| AcpPeerError::Write(io_other("the writer task is gone")))?;
213 Ok(())
214 }
215
216 /// Send a JSON-RPC 2.0 error reply for a
217 /// previously received request. The
218 /// `code` and `message` are the
219 /// JSON-RPC error code and human-readable
220 /// message; the wire form is
221 /// `{ "jsonrpc": "2.0", "id": <id>,
222 /// "error": { "code": <code>, "message":
223 /// <message> } }`.
224 pub async fn send_error(
225 &self,
226 id: Value,
227 code: i64,
228 message: impl Into<String>,
229 ) -> Result<(), AcpPeerError> {
230 let envelope = serde_json::json!({
231 "jsonrpc": "2.0",
232 "id": id,
233 "error": { "code": code, "message": message.into() },
234 });
235 let bytes = serde_json::to_vec(&envelope).map_err(AcpPeerError::Decode)?;
236 let mut framed = bytes;
237 framed.push(b'\n');
238 self.write_tx
239 .send(framed)
240 .await
241 .map_err(|_| AcpPeerError::Write(io_other("the writer task is gone")))?;
242 Ok(())
243 }
244
245 /// Send a notification to the client.
246 /// The wire form is `{ "jsonrpc": "2.0",
247 /// "method": ..., "params": ... }` with
248 /// no `id` field. The schema's
249 /// `session/update` is the canonical
250 /// example.
251 pub async fn send_notification<Params>(
252 &self,
253 method: &str,
254 params: Params,
255 ) -> Result<(), AcpPeerError>
256 where
257 Params: Serialize,
258 {
259 let params_value: Value = serde_json::to_value(¶ms).map_err(AcpPeerError::Decode)?;
260 let envelope = serde_json::json!({
261 "jsonrpc": "2.0",
262 "method": method,
263 "params": params_value,
264 });
265 let bytes = serde_json::to_vec(&envelope).map_err(AcpPeerError::Decode)?;
266 let mut framed = bytes;
267 framed.push(b'\n');
268 self.write_tx
269 .send(framed)
270 .await
271 .map_err(|_| AcpPeerError::Write(io_other("the writer task is gone")))?;
272 Ok(())
273 }
274}
275
276async fn spawn_reader<R>(mut reader: BufReader<R>, request_tx: mpsc::Sender<AgentRequest>)
277where
278 R: tokio::io::AsyncRead + Unpin + Send + 'static,
279{
280 let mut line = String::new();
281 loop {
282 line.clear();
283 match reader.read_line(&mut line).await {
284 Ok(0) | Err(_) => return,
285 Ok(_) => {}
286 }
287 let trimmed = line.trim_end_matches('\n');
288 if trimmed.is_empty() {
289 continue;
290 }
291 // A request carries an `id`; a
292 // notification does not. The agent
293 // peer hands the application only
294 // requests (the application is the
295 // one that wants the correlation);
296 // notifications from the client
297 // (e.g. `session/cancel`) are
298 // silently dropped today. A future
299 // increment exposes them through a
300 // second channel.
301 let value: Value = match serde_json::from_str(trimmed) {
302 Ok(value) => value,
303 Err(_) => continue,
304 };
305 if value.get("id").is_none() {
306 // Notification. Not yet
307 // surfaced to the
308 // application.
309 continue;
310 }
311 let id = value.get("id").cloned().unwrap_or(Value::Null);
312 let method = value
313 .get("method")
314 .and_then(Value::as_str)
315 .unwrap_or("")
316 .to_owned();
317 let params = value.get("params").cloned().unwrap_or(Value::Null);
318 let request = AgentRequest { id, method, params };
319 if request_tx.send(request).await.is_err() {
320 // The application dropped
321 // the receiver; the agent
322 // is shutting down.
323 return;
324 }
325 }
326}
327
328async fn spawn_writer<W>(mut stdout: W, mut write_rx: mpsc::Receiver<Vec<u8>>)
329where
330 W: tokio::io::AsyncWrite + Unpin + Send + 'static,
331{
332 while let Some(bytes) = write_rx.recv().await {
333 if stdout.write_all(&bytes).await.is_err() {
334 return;
335 }
336 if stdout.flush().await.is_err() {
337 return;
338 }
339 }
340}
341
342fn io_other(message: &str) -> std::io::Error {
343 std::io::Error::other(message)
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use tokio::io::AsyncBufReadExt;
350 use tokio::io::BufReader;
351 use tokio::io::duplex;
352
353 /// An agent peer wired to a duplex
354 /// pair reads a request the test
355 /// wrote to the other end. The test
356 /// asserts the request decodes to
357 /// the expected shape.
358 #[tokio::test]
359 async fn a_request_arrives_at_the_agent_peer() -> Result<(), AcpPeerError> {
360 let (mut client_end, agent_end) = duplex(4096);
361 let (agent_read, agent_write) = tokio::io::split(agent_end);
362 let mut peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
363
364 // The "client" writes a request
365 // to its end of the duplex; the
366 // agent peer should surface it.
367 let request_bytes = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"foo\":\"bar\"}}\n";
368 client_end
369 .write_all(request_bytes)
370 .await
371 .map_err(AcpPeerError::Write)?;
372
373 let request = peer
374 .recv_request()
375 .await
376 .ok_or_else(|| AcpPeerError::Write(io_other("no request arrived")))?;
377
378 assert_eq!(request.id, serde_json::json!(1));
379 assert_eq!(request.method, "initialize");
380 assert_eq!(request.params, serde_json::json!({"foo": "bar"}));
381
382 drop(peer);
383 drop(client_end);
384 Ok(())
385 }
386
387 /// A reply the agent sends is the
388 /// test reads from the other end of
389 /// the duplex. The test asserts the
390 /// wire form is well-formed and
391 /// carries the request's `id`.
392 #[tokio::test]
393 async fn a_reply_written_by_the_agent_peer_round_trips() -> Result<(), AcpPeerError> {
394 let (client_end, agent_end) = duplex(4096);
395 let (agent_read, agent_write) = tokio::io::split(agent_end);
396 let peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
397
398 peer.send_reply(serde_json::json!(7), serde_json::json!({"answer": 42}))
399 .await?;
400
401 let line = read_line_from(BufReader::new(client_end)).await?;
402 let value: Value = serde_json::from_str(&line).map_err(AcpPeerError::Decode)?;
403 assert_eq!(value.get("jsonrpc"), Some(&Value::String("2.0".to_owned())));
404 assert_eq!(value.get("id"), Some(&Value::from(7u64)));
405 assert_eq!(
406 value.get("result"),
407 Some(&Value::Object(
408 std::iter::once(("answer".to_owned(), Value::from(42u64))).collect()
409 ))
410 );
411
412 drop(peer);
413 Ok(())
414 }
415
416 /// A notification the agent sends
417 /// has no `id` field on the wire.
418 /// The test asserts the wire form
419 /// is `{ jsonrpc, method, params }`
420 /// only.
421 #[tokio::test]
422 async fn a_notification_written_by_the_agent_peer_omits_the_id() -> Result<(), AcpPeerError> {
423 let (client_end, agent_end) = duplex(4096);
424 let (agent_read, agent_write) = tokio::io::split(agent_end);
425 let peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
426
427 peer.send_notification("session/update", serde_json::json!({"foo": "bar"}))
428 .await?;
429
430 let line = read_line_from(BufReader::new(client_end)).await?;
431 let value: Value = serde_json::from_str(&line).map_err(AcpPeerError::Decode)?;
432 assert!(
433 value.get("id").is_none(),
434 "the notification has no `id` field"
435 );
436 assert_eq!(
437 value.get("method"),
438 Some(&Value::String("session/update".to_owned()))
439 );
440
441 drop(peer);
442 Ok(())
443 }
444
445 /// A JSON-RPC error reply the agent
446 /// sends carries the error code and
447 /// message. The test asserts the
448 /// wire form is `{ jsonrpc, id,
449 /// error: { code, message } }`.
450 #[tokio::test]
451 async fn an_error_reply_written_by_the_agent_peer_round_trips() -> Result<(), AcpPeerError> {
452 let (client_end, agent_end) = duplex(4096);
453 let (agent_read, agent_write) = tokio::io::split(agent_end);
454 let peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
455
456 peer.send_error(serde_json::json!(3), -32601, "Method not found")
457 .await?;
458
459 let line = read_line_from(BufReader::new(client_end)).await?;
460 let value: Value = serde_json::from_str(&line).map_err(AcpPeerError::Decode)?;
461 assert_eq!(value.get("id"), Some(&Value::from(3u64)));
462 let error = value
463 .get("error")
464 .ok_or_else(|| AcpPeerError::Write(io_other("missing `error` field")))?;
465 assert_eq!(error.get("code"), Some(&Value::from(-32601)));
466 assert_eq!(
467 error.get("message"),
468 Some(&Value::String("Method not found".to_owned()))
469 );
470
471 drop(peer);
472 Ok(())
473 }
474
475 /// Read one `\n`-terminated line from
476 /// the buffered reader. The helper
477 /// exists so the wire-shape tests can
478 /// consume the agent peer's output
479 /// without depending on
480 /// `tokio::io::AsyncReadExt::read_buf`'s
481 /// "may return short" semantics. The
482 /// trailing newline is stripped so the
483 /// caller parses the JSON object
484 /// directly.
485 async fn read_line_from<R>(mut reader: BufReader<R>) -> Result<String, AcpPeerError>
486 where
487 R: tokio::io::AsyncRead + Unpin + Send,
488 {
489 let mut line = String::new();
490 reader
491 .read_line(&mut line)
492 .await
493 .map_err(AcpPeerError::Write)?;
494 Ok(line.trim_end_matches('\n').to_owned())
495 }
496}