zeph_acp/client/mod.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! ACP sub-agent client.
5//!
6//! This module lets Zeph act as a **client** that spawns an external ACP-compatible
7//! agent subprocess and communicates with it using the Agent Client Protocol. It is
8//! the inverse of `zeph-acp`'s server role: rather than accepting connections from
9//! IDEs, this client *initiates* a connection to a child process.
10//!
11//! # Architecture
12//!
13//! ```text
14//! SubagentHandle ──cmd_tx──► driver task ──JSON-RPC──► child process
15//! │ │ │
16//! │◄──ready_rx─────────────────┘ │
17//! │ │
18//! └──send_prompt / read_update / close │
19//! ```
20//!
21//! The driver runs inside `Client.builder().connect_with(...)` and serialises all
22//! ACP operations through a command channel. Callers interact only with
23//! [`SubagentHandle`].
24//!
25//! # Quick start
26//!
27//! ```no_run
28//! use zeph_acp::client::{SubagentConfig, spawn_subagent};
29//!
30//! # async fn example() -> Result<(), zeph_acp::client::AcpClientError> {
31//! let cfg = SubagentConfig {
32//! command: "cargo run --quiet -- --acp".to_owned(),
33//! auto_approve_permissions: true,
34//! ..SubagentConfig::default()
35//! };
36//!
37//! let outcome = run_session(cfg, "hello").await?;
38//! println!("{}", outcome.text);
39//! # Ok(())
40//! # }
41//!
42//! # use zeph_acp::client::run_session;
43//! ```
44
45pub mod config;
46pub mod error;
47
48pub(crate) mod driver;
49pub(crate) mod transport;
50
51pub use config::{AcpSubagentsConfig, SubagentConfig, SubagentPresetConfig};
52pub use error::{AcpClientError, HandshakeStep};
53
54use std::sync::{Arc, Mutex};
55use std::time::Duration;
56
57use agent_client_protocol::{
58 Agent, Client, SessionMessage, on_receive_request,
59 schema::v1::{
60 RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
61 SelectedPermissionOutcome, SessionId, StopReason,
62 },
63};
64use futures::channel::mpsc;
65use tokio::sync::oneshot;
66use tracing::Instrument;
67
68use driver::SubagentCommand;
69
70/// The outcome of a completed sub-agent session.
71///
72/// Contains the concatenated text output and the final stop reason.
73#[derive(Debug, Clone)]
74pub struct RunOutcome {
75 /// All `AgentMessageChunk::Text` content concatenated in order.
76 pub text: String,
77 /// The reason the agent stopped generating.
78 pub stop_reason: StopReason,
79}
80
81/// A live handle to a spawned ACP sub-agent session.
82///
83/// `SubagentHandle` serialises all ACP operations through a command channel that
84/// is serviced by a background driver task. Concurrent reads are rejected with
85/// [`AcpClientError::DriverBusy`]; callers must wait for the in-flight operation
86/// to complete before issuing another read.
87///
88/// Dropping the handle without calling [`close`](Self::close) aborts the driver
89/// task, which in turn kills the subprocess via `kill_on_drop`.
90pub struct SubagentHandle {
91 cmd_tx: mpsc::UnboundedSender<SubagentCommand>,
92 join_handle: tokio::task::JoinHandle<()>,
93 session_id: SessionId,
94 closed: bool,
95 prompt_timeout: Duration,
96}
97
98impl SubagentHandle {
99 /// The ACP `SessionId` assigned by the sub-agent.
100 #[must_use]
101 pub fn session_id(&self) -> &SessionId {
102 &self.session_id
103 }
104
105 /// Construct a handle wired to an external command channel.
106 ///
107 /// Used in tests to inject a mock driver without spawning a subprocess.
108 #[cfg(test)]
109 pub(crate) fn new_for_test(
110 cmd_tx: mpsc::UnboundedSender<SubagentCommand>,
111 join_handle: tokio::task::JoinHandle<()>,
112 session_id: SessionId,
113 ) -> Self {
114 Self {
115 cmd_tx,
116 join_handle,
117 session_id,
118 closed: false,
119 prompt_timeout: Duration::from_secs(30),
120 }
121 }
122
123 /// Send a text prompt to the sub-agent.
124 ///
125 /// Returns immediately after enqueuing the prompt — the sub-agent will
126 /// process it asynchronously. Call [`read_update`](Self::read_update) or
127 /// [`read_to_string`](Self::read_to_string) to receive the response.
128 ///
129 /// # Errors
130 ///
131 /// Returns [`AcpClientError::Closed`] when the session has been closed,
132 /// [`AcpClientError::DriverDied`] when the background driver has exited
133 /// unexpectedly, or [`AcpClientError::Sdk`] for protocol errors.
134 /// # Examples
135 ///
136 /// ```no_run
137 /// use zeph_acp::client::{SubagentConfig, spawn_subagent};
138 ///
139 /// # async fn example() -> Result<(), zeph_acp::client::AcpClientError> {
140 /// let cfg = SubagentConfig { command: "zeph --acp".to_owned(), ..SubagentConfig::default() };
141 /// let mut handle = spawn_subagent(cfg).await?;
142 /// handle.send_prompt("What is 2 + 2?").await?;
143 /// # Ok(())
144 /// # }
145 /// ```
146 pub async fn send_prompt(&mut self, text: impl Into<String>) -> Result<(), AcpClientError> {
147 if self.closed {
148 return Err(AcpClientError::Closed);
149 }
150 let span = tracing::info_span!("acp.client.prompt");
151 async {
152 let (tx, rx) = oneshot::channel();
153 self.cmd_tx
154 .unbounded_send(SubagentCommand::Prompt {
155 text: text.into(),
156 reply: tx,
157 })
158 .map_err(|_| AcpClientError::DriverDied)?;
159 rx.await.map_err(|_| AcpClientError::DriverDied)?
160 }
161 .instrument(span)
162 .await
163 }
164
165 /// Read one `SessionMessage` update from the sub-agent.
166 ///
167 /// Blocks until an update arrives or the session closes. A concurrent call
168 /// returns [`AcpClientError::DriverBusy`] immediately.
169 ///
170 /// # Errors
171 ///
172 /// Returns [`AcpClientError::Closed`] or [`AcpClientError::DriverDied`] when
173 /// the session ends, or [`AcpClientError::DriverBusy`] when a concurrent read
174 /// is already in progress.
175 /// # Examples
176 ///
177 /// ```no_run
178 /// use zeph_acp::client::{SubagentConfig, spawn_subagent};
179 ///
180 /// # async fn example() -> Result<(), zeph_acp::client::AcpClientError> {
181 /// let cfg = SubagentConfig { command: "zeph --acp".to_owned(), ..SubagentConfig::default() };
182 /// let mut handle = spawn_subagent(cfg).await?;
183 /// handle.send_prompt("hello").await?;
184 /// let update = handle.read_update().await?;
185 /// # drop(update);
186 /// # Ok(())
187 /// # }
188 /// ```
189 pub async fn read_update(&mut self) -> Result<SessionMessage, AcpClientError> {
190 if self.closed {
191 return Err(AcpClientError::Closed);
192 }
193 let span = tracing::info_span!("acp.client.read_update");
194 async {
195 let (tx, rx) = oneshot::channel();
196 self.cmd_tx
197 .unbounded_send(SubagentCommand::ReadUpdate { reply: tx })
198 .map_err(|_| AcpClientError::DriverDied)?;
199 rx.await.map_err(|_| AcpClientError::DriverDied)?
200 }
201 .instrument(span)
202 .await
203 }
204
205 /// Drain all updates until `StopReason`, collecting text into a [`RunOutcome`].
206 ///
207 /// Equivalent to calling `read_update` in a loop, filtering for text chunks and
208 /// terminating on `StopReason`. Ignores thought chunks, tool calls, and plans.
209 ///
210 /// A [`send_cancel`](Self::send_cancel) issued concurrently will interrupt the
211 /// drain and the in-flight read will resolve with `StopReason::Cancelled`.
212 ///
213 /// # Errors
214 ///
215 /// Returns [`AcpClientError::Closed`] or [`AcpClientError::DriverDied`] when
216 /// the session ends, or [`AcpClientError::DriverBusy`] when another read is in
217 /// progress.
218 pub async fn read_to_string(&mut self) -> Result<RunOutcome, AcpClientError> {
219 if self.closed {
220 return Err(AcpClientError::Closed);
221 }
222 let timeout = self.prompt_timeout;
223 let span = tracing::info_span!("acp.client.read_to_string");
224 async {
225 let (tx, rx) = oneshot::channel();
226 self.cmd_tx
227 .unbounded_send(SubagentCommand::ReadToString { reply: tx })
228 .map_err(|_| AcpClientError::DriverDied)?;
229 tokio::time::timeout(timeout, rx)
230 .await
231 .map_err(|_| AcpClientError::Timeout)?
232 .map_err(|_| AcpClientError::DriverDied)?
233 }
234 .instrument(span)
235 .await
236 }
237
238 /// Send a `session/cancel` notification to the sub-agent.
239 ///
240 /// This does not close the session; the sub-agent should acknowledge the cancel
241 /// by sending a `StopReason::Cancelled` update on the active read.
242 ///
243 /// The one-poll-cycle preemption guarantee (a cancel delivered while
244 /// [`read_update`](Self::read_update) or [`read_to_string`](Self::read_to_string) is blocked
245 /// will interrupt the read within one `tokio::select!` cycle) only applies when one of those
246 /// read operations is currently in progress. Calling `send_cancel` outside of an active read
247 /// sends the ACP notification but does not interrupt any future read.
248 ///
249 /// # Errors
250 ///
251 /// Returns [`AcpClientError::Closed`] when the session is already closed or
252 /// [`AcpClientError::DriverDied`] if the driver has exited.
253 pub async fn send_cancel(&mut self) -> Result<(), AcpClientError> {
254 if self.closed {
255 return Err(AcpClientError::Closed);
256 }
257 let span = tracing::info_span!("acp.client.cancel");
258 async {
259 let (tx, rx) = oneshot::channel();
260 self.cmd_tx
261 .unbounded_send(SubagentCommand::Cancel { reply: tx })
262 .map_err(|_| AcpClientError::DriverDied)?;
263 rx.await.map_err(|_| AcpClientError::DriverDied)?
264 }
265 .instrument(span)
266 .await
267 }
268
269 /// Close the session and wait for the driver to shut down.
270 ///
271 /// Idempotent: a second call returns [`AcpClientError::Closed`] immediately.
272 ///
273 /// # Errors
274 ///
275 /// Returns [`AcpClientError::DriverDied`] if the driver exited before the
276 /// close acknowledgement was received.
277 /// # Examples
278 ///
279 /// ```no_run
280 /// use zeph_acp::client::{SubagentConfig, spawn_subagent};
281 ///
282 /// # async fn example() -> Result<(), zeph_acp::client::AcpClientError> {
283 /// let cfg = SubagentConfig { command: "zeph --acp".to_owned(), ..SubagentConfig::default() };
284 /// let mut handle = spawn_subagent(cfg).await?;
285 /// handle.close().await?;
286 /// # Ok(())
287 /// # }
288 /// ```
289 pub async fn close(&mut self) -> Result<(), AcpClientError> {
290 if self.closed {
291 return Err(AcpClientError::Closed);
292 }
293 self.closed = true;
294 let span = tracing::info_span!("acp.client.close");
295 async {
296 let (tx, rx) = oneshot::channel();
297 let _ = self
298 .cmd_tx
299 .unbounded_send(SubagentCommand::Close { ack: tx });
300 // Best-effort acknowledgement with a short timeout — driver may have already exited.
301 let _ = tokio::time::timeout(Duration::from_secs(5), rx).await;
302 self.join_handle.abort();
303 Ok(())
304 }
305 .instrument(span)
306 .await
307 }
308}
309
310impl Drop for SubagentHandle {
311 fn drop(&mut self) {
312 self.join_handle.abort();
313 }
314}
315
316/// Spawn a sub-agent subprocess and complete the ACP handshake.
317///
318/// Returns a [`SubagentHandle`] once the `initialize` + `session/new` handshake
319/// succeeds. The handle can then be used to send prompts and read responses.
320///
321/// The subprocess is spawned with a cleared environment (`env_clear`) so no
322/// `ZEPH_*` secrets are forwarded. `kill_on_drop(true)` ensures the child is
323/// reaped when the handle is dropped.
324///
325/// # Errors
326///
327/// Returns [`AcpClientError::InvalidConfig`] for bad command strings,
328/// [`AcpClientError::Spawn`] for OS spawn failures,
329/// [`AcpClientError::Handshake`] for protocol handshake failures,
330/// or [`AcpClientError::Timeout`] when the handshake exceeds `handshake_timeout_secs`.
331///
332/// # Examples
333///
334/// ```no_run
335/// use zeph_acp::client::{SubagentConfig, spawn_subagent};
336///
337/// # async fn example() -> Result<(), zeph_acp::client::AcpClientError> {
338/// let cfg = SubagentConfig {
339/// command: "zeph --acp".to_owned(),
340/// auto_approve_permissions: true,
341/// ..SubagentConfig::default()
342/// };
343/// let handle = spawn_subagent(cfg).await?;
344/// # drop(handle);
345/// # Ok(())
346/// # }
347/// ```
348pub async fn spawn_subagent(cfg: SubagentConfig) -> Result<SubagentHandle, AcpClientError> {
349 // The span covers only the handshake phase (up to session_id resolution).
350 // The driver task lifetime is not included — it runs independently after this returns.
351 let span = tracing::info_span!("acp.client.connect");
352 spawn_subagent_inner(cfg).instrument(span).await
353}
354
355async fn spawn_subagent_inner(cfg: SubagentConfig) -> Result<SubagentHandle, AcpClientError> {
356 let spawned = transport::spawn_child(&cfg)?;
357
358 let (cmd_tx, cmd_rx) = mpsc::unbounded::<SubagentCommand>();
359 let (ready_tx, ready_rx) = oneshot::channel::<Result<SessionId, AcpClientError>>();
360 let ready_slot = Arc::new(Mutex::new(Some(ready_tx)));
361
362 let transport = transport::make_byte_streams(spawned.stdin, spawned.stdout);
363 let auto_approve = cfg.auto_approve_permissions;
364 let handshake_timeout = Duration::from_secs(cfg.handshake_timeout_secs);
365 let prompt_timeout = Duration::from_secs(cfg.prompt_timeout_secs);
366
367 let ready_slot_clone = ready_slot.clone();
368 let cfg_clone = cfg.clone();
369 let child = spawned.child;
370 let stderr_task = transport::spawn_stderr_drain(spawned.stderr, "pending".to_owned());
371
372 // EXEMPT(#5144): main ACP client driver; join_handle is stored on the Driver struct
373 // and aborted on drop — joinable handle lifecycle required.
374 let join_handle =
375 tokio::spawn(async move {
376 let result = Client
377 .builder()
378 .on_receive_request(
379 async move |req: RequestPermissionRequest,
380 responder: agent_client_protocol::Responder<RequestPermissionResponse>,
381 _cx: agent_client_protocol::ConnectionTo<Agent>| {
382 let outcome = if auto_approve {
383 if let Some(opt) = req.options.first() {
384 RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
385 opt.option_id.clone(),
386 ))
387 } else {
388 RequestPermissionOutcome::Cancelled
389 }
390 } else {
391 RequestPermissionOutcome::Cancelled
392 };
393 let _ = responder.respond(RequestPermissionResponse::new(outcome));
394 Ok(())
395 },
396 on_receive_request!(),
397 )
398 .connect_with(transport, move |cx: agent_client_protocol::ConnectionTo<Agent>| {
399 let ready_slot = ready_slot_clone;
400 let cfg = cfg_clone;
401 async move {
402 driver::run_driver(cx, cmd_rx, ready_slot, cfg, child, stderr_task).await
403 }
404 })
405 .await;
406
407 if let Err(e) = result {
408 tracing::debug!(error = %e, "acp.client.connect: transport closed");
409 }
410 });
411
412 // Wait for the handshake to complete (or fail) within the timeout.
413 // On timeout we abort the spawned task so the child process and stderr-drain
414 // task are cleaned up rather than leaking as zombies.
415 let session_id = match tokio::time::timeout(handshake_timeout, ready_rx).await {
416 Ok(Ok(Ok(id))) => id,
417 Ok(Ok(Err(e))) => {
418 join_handle.abort();
419 return Err(e);
420 }
421 Ok(Err(_)) => {
422 join_handle.abort();
423 return Err(AcpClientError::DriverDied);
424 }
425 Err(_) => {
426 join_handle.abort();
427 return Err(AcpClientError::Timeout);
428 }
429 };
430
431 Ok(SubagentHandle {
432 cmd_tx,
433 join_handle,
434 session_id,
435 closed: false,
436 prompt_timeout,
437 })
438}
439
440/// Convenience function: spawn a sub-agent, send one prompt, and drain to string.
441///
442/// Wraps [`spawn_subagent`] + [`SubagentHandle::send_prompt`] +
443/// [`SubagentHandle::read_to_string`] into a single call. The session is
444/// closed after the response is received.
445///
446/// # Errors
447///
448/// Propagates any error from the underlying handle methods.
449///
450/// # Examples
451///
452/// ```no_run
453/// use zeph_acp::client::{SubagentConfig, run_session};
454///
455/// # async fn example() -> Result<(), zeph_acp::client::AcpClientError> {
456/// let cfg = SubagentConfig {
457/// command: "zeph --acp".to_owned(),
458/// auto_approve_permissions: true,
459/// ..SubagentConfig::default()
460/// };
461/// let outcome = run_session(cfg, "What is 2 + 2?").await?;
462/// println!("{}", outcome.text);
463/// # Ok(())
464/// # }
465/// ```
466pub async fn run_session(
467 cfg: SubagentConfig,
468 prompt: impl Into<String>,
469) -> Result<RunOutcome, AcpClientError> {
470 let span = tracing::info_span!("acp.client.session.run");
471 run_session_inner(cfg, prompt.into()).instrument(span).await
472}
473
474async fn run_session_inner(
475 cfg: SubagentConfig,
476 prompt: String,
477) -> Result<RunOutcome, AcpClientError> {
478 let session_timeout = Duration::from_secs(cfg.session_timeout_secs);
479 let mut handle = spawn_subagent(cfg).await?;
480 let result = tokio::time::timeout(session_timeout, async {
481 handle.send_prompt(prompt).await?;
482 handle.read_to_string().await
483 })
484 .await
485 .map_err(|_| AcpClientError::Timeout)?;
486 let _ = handle.close().await;
487 result
488}
489
490#[cfg(test)]
491mod tests {
492 include!("tests.rs");
493}