Skip to main content

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}