Skip to main content

adk_acp/
streaming.rs

1//! Streaming output from ACP agent sessions.
2//!
3//! Instead of collecting the full response into a string, streaming mode
4//! yields chunks as they arrive from the agent — enabling real-time display
5//! and lower time-to-first-token.
6
7use std::str::FromStr;
8use std::sync::Arc;
9
10use agent_client_protocol::schema::{
11    ContentBlock, InitializeRequest, ProtocolVersion, RequestPermissionOutcome,
12    RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
13    SessionNotification, SessionUpdate,
14};
15use agent_client_protocol::{Agent, Client, ConnectionTo};
16use agent_client_protocol_tokio::AcpAgent;
17use tokio::sync::mpsc;
18use tracing::{info, warn};
19
20use crate::connection::AcpAgentConfig;
21use crate::error::{AcpError, Result};
22use crate::permissions::{
23    PermissionDecision, PermissionOption, PermissionPolicy, PermissionRequest,
24};
25use crate::status::{AgentStatus, StatusTracker};
26
27/// A chunk of output from the ACP agent.
28#[derive(Debug, Clone)]
29pub enum OutputChunk {
30    /// A text chunk from the agent's response.
31    Text(String),
32    /// The agent is thinking (internal reasoning, not shown to user by default).
33    Thought(String),
34    /// A tool call was initiated (e.g., "Creating file app.rs").
35    ToolCall {
36        /// Human-readable title of the operation.
37        title: String,
38    },
39    /// A tool call completed.
40    ToolCallComplete {
41        /// Human-readable title.
42        title: String,
43    },
44    /// The agent requested permission (informational — decision already made by policy).
45    PermissionRequested {
46        /// What the agent wanted to do.
47        title: String,
48        /// Whether it was approved.
49        approved: bool,
50    },
51    /// The agent finished responding.
52    Done,
53    /// An error occurred.
54    Error(String),
55}
56
57/// A streaming receiver for ACP agent output.
58///
59/// Yields [`OutputChunk`]s as they arrive from the agent.
60///
61/// # Example
62///
63/// ```rust,ignore
64/// use adk_acp::streaming::stream_prompt;
65///
66/// let mut stream = stream_prompt(&config, "Write a hello world", policy, status).await?;
67/// while let Some(chunk) = stream.recv().await {
68///     match chunk {
69///         OutputChunk::Text(t) => print!("{t}"),
70///         OutputChunk::ToolCall { title } => println!("\n[tool] {title}"),
71///         OutputChunk::Done => break,
72///         _ => {}
73///     }
74/// }
75/// ```
76pub type OutputStream = mpsc::Receiver<OutputChunk>;
77
78/// Send a prompt and stream the response chunks.
79///
80/// Returns a receiver that yields [`OutputChunk`]s as they arrive.
81/// The agent process is terminated when the stream completes.
82pub async fn stream_prompt(
83    config: &AcpAgentConfig,
84    prompt: &str,
85    policy: Arc<PermissionPolicy>,
86    status: StatusTracker,
87) -> Result<OutputStream> {
88    info!(command = %config.command, "starting streaming ACP prompt");
89
90    let command_with_env = crate::connection::build_command_with_env(&config.command, &config.env);
91
92    let agent = AcpAgent::from_str(&command_with_env).map_err(|e| {
93        AcpError::InvalidConfig(format!("invalid command '{}': {e}", config.command))
94    })?;
95
96    let (chunk_tx, chunk_rx) = mpsc::channel::<OutputChunk>(64);
97    let prompt_text = prompt.to_string();
98    let working_dir = config.working_dir.clone();
99
100    status.set(AgentStatus::Starting);
101
102    tokio::spawn(async move {
103        let chunk_tx_err = chunk_tx.clone();
104        let status_inner = status.clone();
105        let policy_clone = policy.clone();
106        let chunk_tx_perm = chunk_tx.clone();
107
108        let outcome = Client
109            .builder()
110            .on_receive_notification(
111                {
112                    let tx = chunk_tx.clone();
113                    async move |notif: SessionNotification, _cx: ConnectionTo<Agent>| {
114                        match notif.update {
115                            SessionUpdate::AgentMessageChunk(chunk) => {
116                                if let ContentBlock::Text(text_content) = chunk.content {
117                                    let _ = tx
118                                        .send(OutputChunk::Text(text_content.text.to_string()))
119                                        .await;
120                                }
121                            }
122                            SessionUpdate::AgentThoughtChunk(chunk) => {
123                                if let ContentBlock::Text(text_content) = chunk.content {
124                                    let _ = tx
125                                        .send(OutputChunk::Thought(text_content.text.to_string()))
126                                        .await;
127                                }
128                            }
129                            SessionUpdate::ToolCall(tool_call) => {
130                                let _ = tx
131                                    .send(OutputChunk::ToolCall {
132                                        title: tool_call.title.to_string(),
133                                    })
134                                    .await;
135                            }
136                            _ => {}
137                        }
138                        Ok(())
139                    }
140                },
141                agent_client_protocol::on_receive_notification!(),
142            )
143            .on_receive_request(
144                {
145                    let status = status_inner.clone();
146                    async move |request: RequestPermissionRequest,
147                                responder,
148                                _cx: ConnectionTo<Agent>| {
149                        status.set(AgentStatus::WaitingPermission);
150
151                        let title = request
152                            .options
153                            .first()
154                            .map(|o| o.name.to_string())
155                            .unwrap_or_else(|| "Unknown".to_string());
156
157                        let perm_request = PermissionRequest {
158                            title: title.clone(),
159                            options: request
160                                .options
161                                .iter()
162                                .map(|o| PermissionOption {
163                                    id: o.option_id.to_string(),
164                                    name: o.name.to_string(),
165                                })
166                                .collect(),
167                        };
168
169                        let decision = policy_clone.decide(&perm_request);
170                        let approved = matches!(decision, PermissionDecision::Allow(_));
171
172                        let _ = chunk_tx_perm
173                            .send(OutputChunk::PermissionRequested {
174                                title: title.clone(),
175                                approved,
176                            })
177                            .await;
178
179                        status.set(AgentStatus::Running);
180
181                        match decision {
182                            PermissionDecision::Allow(id) => responder.respond(
183                                RequestPermissionResponse::new(RequestPermissionOutcome::Selected(
184                                    SelectedPermissionOutcome::new(id),
185                                )),
186                            ),
187                            PermissionDecision::Deny => responder.respond(
188                                RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled),
189                            ),
190                        }
191                    }
192                },
193                agent_client_protocol::on_receive_request!(),
194            )
195            .connect_with(agent, {
196                let status = status_inner.clone();
197                let tx = chunk_tx.clone();
198                |connection: ConnectionTo<Agent>| async move {
199                    status.set(AgentStatus::Starting);
200
201                    connection
202                        .send_request(InitializeRequest::new(ProtocolVersion::V1))
203                        .block_task()
204                        .await?;
205
206                    status.set(AgentStatus::Running);
207
208                    connection
209                        .build_session(&working_dir)
210                        .block_task()
211                        .run_until(async |mut session| {
212                            session.send_prompt(&prompt_text)?;
213                            // read_to_string collects internally; notifications stream via callback
214                            let _ = session.read_to_string().await?;
215                            let _ = tx.send(OutputChunk::Done).await;
216                            Ok(())
217                        })
218                        .await?;
219
220                    status.set(AgentStatus::Idle);
221                    Ok(())
222                }
223            })
224            .await;
225
226        if let Err(e) = outcome {
227            warn!(error = %e, "streaming ACP session ended with error");
228            let _ = chunk_tx_err.send(OutputChunk::Error(e.to_string())).await;
229        }
230
231        status_inner.set(AgentStatus::Stopped);
232    });
233
234    Ok(chunk_rx)
235}