acp_utils/client/
tokio_agent.rs1use agent_client_protocol::util::internal_error;
8use agent_client_protocol::{
9 AcpAgent, AcpAgentConfig, ByteStreams, ConnectTo, Error, INCOMING_TRANSPORT_CLOSED_REASON, Role,
10 is_incoming_transport_closed,
11};
12use std::path::PathBuf;
13use std::process::{ExitStatus, Stdio};
14use std::str::FromStr;
15use tokio::io::{AsyncBufReadExt, BufReader};
16use tokio::process::Command;
17use tokio::sync::oneshot;
18use tokio::time::{Duration, timeout};
19use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
20
21pub struct TokioAcpAgent {
22 config: AcpAgentConfig,
23}
24
25impl TokioAcpAgent {
26 pub fn from_command(command: impl Into<PathBuf>, args: Vec<String>) -> Self {
27 Self { config: AcpAgentConfig::new(command).args(args) }
28 }
29
30 pub fn config(&self) -> &AcpAgentConfig {
31 &self.config
32 }
33}
34
35impl<T: Role> ConnectTo<T> for TokioAcpAgent {
36 async fn connect_to(self, client: impl ConnectTo<T::Counterpart>) -> Result<(), Error> {
37 connect_stdio::<T>(self.config, client).await
38 }
39}
40
41impl FromStr for TokioAcpAgent {
42 type Err = Error;
43
44 fn from_str(s: &str) -> Result<Self, Self::Err> {
45 Ok(Self { config: AcpAgent::from_str(s)?.into_config() })
46 }
47}
48
49async fn connect_stdio<T: Role>(config: AcpAgentConfig, client: impl ConnectTo<T::Counterpart>) -> Result<(), Error> {
50 let (stdin, stdout, stderr, mut child) = {
51 let mut cmd = Command::new(config.command());
52 cmd.args(config.arguments());
53 for (name, value) in config.environment() {
54 cmd.env(name, value);
55 }
56
57 let mut child = cmd
58 .stdin(Stdio::piped())
59 .stdout(Stdio::piped())
60 .stderr(Stdio::piped())
61 .kill_on_drop(true)
62 .spawn()
63 .map_err(Error::into_internal_error)?;
64
65 let stdin = child.stdin.take().ok_or_else(|| internal_error("missing child stdin"))?;
66 let stdout = child.stdout.take().ok_or_else(|| internal_error("missing child stdout"))?;
67 let stderr = child.stderr.take().ok_or_else(|| internal_error("missing child stderr"))?;
68 (stdin, stdout, stderr, child)
69 };
70
71 let (stderr_tx, stderr_rx) = oneshot::channel::<String>();
72 tokio::spawn(async move {
73 let mut lines = BufReader::new(stderr).lines();
74 let mut buf = String::new();
75 while let Ok(Some(line)) = lines.next_line().await {
76 if !buf.is_empty() {
77 buf.push('\n');
78 }
79 buf.push_str(&line);
80 }
81 let _ = stderr_tx.send(buf);
82 });
83
84 let child_fut = async move {
85 let status = child.wait().await.map_err(Error::into_internal_error)?;
86 finish_child_exit(status, stderr_rx).await
87 };
88
89 let bytes = ByteStreams::new(stdin.compat_write(), stdout.compat());
90 let protocol_fut = ConnectTo::<T>::connect_to(bytes, client);
91 tokio::pin!(child_fut);
92
93 tokio::select! {
94 result = &mut child_fut => result,
95 result = protocol_fut => match result {
96 Ok(()) => timeout(SHUTDOWN_GRACE_PERIOD, &mut child_fut).await.unwrap_or(Ok(())),
97 Err(protocol_error) if has_incoming_transport_closed(&protocol_error) => {
98 match timeout(SHUTDOWN_GRACE_PERIOD, &mut child_fut).await {
99 Ok(Err(child_error)) => Err(child_error),
100 _ => Err(protocol_error),
101 }
102 }
103 Err(error) => Err(error),
104 },
105 }
106}
107
108fn has_incoming_transport_closed(error: &Error) -> bool {
109 fn data_has_reason(data: &serde_json::Value) -> bool {
110 data.get("reason").and_then(serde_json::Value::as_str) == Some(INCOMING_TRANSPORT_CLOSED_REASON)
111 || data.get("data").is_some_and(data_has_reason)
112 }
113
114 is_incoming_transport_closed(error) || error.data.as_ref().is_some_and(data_has_reason)
115}
116
117async fn finish_child_exit(status: ExitStatus, stderr_rx: oneshot::Receiver<String>) -> Result<(), Error> {
118 if status.success() {
119 return Ok(());
120 }
121
122 let stderr = match timeout(SHUTDOWN_GRACE_PERIOD, stderr_rx).await {
123 Ok(Ok(stderr)) => stderr,
124 _ => String::new(),
125 };
126 let message = if stderr.is_empty() {
127 format!("agent process exited ({status})")
128 } else {
129 format!("agent process exited ({status}): {stderr}")
130 };
131
132 Err(internal_error(message))
133}
134
135const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(1);