Skip to main content

agy_bridge/streaming/
handle.rs

1//! The receiving/reading side of the streaming channel pair.
2
3use std::sync::{Arc, Mutex, atomic::Ordering};
4
5use tokio::sync::mpsc;
6use tokio_stream::wrappers::ReceiverStream;
7
8use super::types::{
9    ChatResponseSharedState, ChatResult, ERROR_DRAIN_TIMEOUT, ResponseEvent, StreamChunk,
10    StreamError, StreamReceivers, StreamSubscriptions, ToolCallEvent,
11};
12use crate::types::{Step, UsageMetadata};
13
14/// Handle to a streaming chat response.
15///
16/// Created by [`AgentHandle::chat()`](crate::agent::AgentHandle::chat). Provides
17/// independent channels for text tokens, thinking tokens, and tool-call events.
18///
19/// Each stream accessor can only be called once — subsequent calls return `None`
20/// because the underlying receiver has already been taken.
21#[derive(Debug)]
22pub struct ChatResponseHandle {
23    /// All per-stream receivers, grouped for clarity.
24    pub(super) rx: StreamReceivers,
25    /// Per-view subscription flags, shared with the writer.
26    ///
27    /// Set when the consumer attaches to a stream so the writer only fans out
28    /// to channels that are actually being drained.
29    pub(super) subs: Arc<StreamSubscriptions>,
30    /// Token usage metadata, populated after the stream completes.
31    pub(super) usage: Option<UsageMetadata>,
32    /// Structured output from a `response_schema`-configured agent.
33    pub(super) structured_output_value: Option<serde_json::Value>,
34    /// Shared state to receive metadata updates from the python bridge thread.
35    pub(crate) shared_state: Arc<Mutex<ChatResponseSharedState>>,
36}
37
38impl ChatResponseHandle {
39    /// Take the text token receiver for token-by-token streaming.
40    ///
41    /// Returns `None` if the receiver was already taken.
42    pub fn take_text_stream(&mut self) -> Option<mpsc::Receiver<String>> {
43        self.subs.text.store(true, Ordering::Release);
44        self.rx.text.take()
45    }
46
47    /// Take the thinking token receiver.
48    ///
49    /// Returns `None` if the receiver was already taken.
50    pub fn take_thought_stream(&mut self) -> Option<mpsc::Receiver<String>> {
51        self.subs.thought.store(true, Ordering::Release);
52        self.rx.thought.take()
53    }
54
55    /// Take the tool call event receiver.
56    ///
57    /// Returns `None` if the receiver was already taken.
58    pub fn take_tool_call_stream(&mut self) -> Option<mpsc::Receiver<ToolCallEvent>> {
59        self.subs.tool_call.store(true, Ordering::Release);
60        self.rx.tool_call.take()
61    }
62
63    /// Take the raw step receiver.
64    ///
65    /// Returns `None` if the receiver was already taken.
66    /// Prefer [`receive_steps()`](Self::receive_steps) for `StreamExt`-compatible usage.
67    pub fn take_step_stream(&mut self) -> Option<mpsc::Receiver<Step>> {
68        self.subs.step.store(true, Ordering::Release);
69        self.rx.step.take()
70    }
71
72    /// Take the step stream for consuming with `StreamExt::next()`.
73    ///
74    /// Returns `None` if the stream was already taken.
75    ///
76    /// # Example
77    ///
78    /// ```
79    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
80    /// use agy_bridge::streaming;
81    /// use tokio_stream::StreamExt;
82    ///
83    /// let (_writer, mut handle) = streaming::channel();
84    /// drop(_writer); // close the channel so the stream ends
85    /// let mut steps = handle.receive_steps().unwrap();
86    /// while let Some(step) = steps.next().await {
87    ///     println!("step: {:?}", step.step_type);
88    /// }
89    /// # });
90    pub fn receive_steps(&mut self) -> Option<impl tokio_stream::Stream<Item = Step>> {
91        self.subs.step.store(true, Ordering::Release);
92        self.rx.step.take().map(ReceiverStream::new)
93    }
94
95    /// Take the unified chunk stream for consuming with `StreamExt::next()`.
96    ///
97    /// Returns `None` if the stream was already taken.
98    ///
99    /// # Example
100    ///
101    /// ```
102    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
103    /// use agy_bridge::streaming::{self, StreamChunk};
104    /// use tokio_stream::StreamExt;
105    ///
106    /// let (_writer, mut handle) = streaming::channel();
107    /// drop(_writer); // close the channel so the stream ends
108    /// let mut chunks = handle.receive_chunks().unwrap();
109    /// while let Some(chunk) = chunks.next().await {
110    ///     match chunk {
111    ///         StreamChunk::Text(t) => print!("{t}"),
112    ///         StreamChunk::Thought(t) => eprintln!("thought: {t}"),
113    ///         StreamChunk::ToolCall(tc) => eprintln!("tool: {}", tc.name),
114    ///         _ => {}
115    ///     }
116    /// }
117    /// # });
118    pub fn receive_chunks(&mut self) -> Option<impl tokio_stream::Stream<Item = StreamChunk>> {
119        self.subs.chunk.store(true, Ordering::Release);
120        self.rx.chunk.take().map(ReceiverStream::new)
121    }
122
123    /// Take the unified chunk receiver as a plain `mpsc::Receiver`.
124    ///
125    /// Returns `None` if the receiver was already taken. Prefer
126    /// [`receive_chunks()`](Self::receive_chunks) for `StreamExt`-compatible
127    /// usage; this variant exists for consumers that drain the channel with
128    /// `recv()`.
129    pub fn take_chunk_stream(&mut self) -> Option<mpsc::Receiver<StreamChunk>> {
130        self.subs.chunk.store(true, Ordering::Release);
131        self.rx.chunk.take()
132    }
133
134    /// Take the timeline event receiver.
135    ///
136    /// Returns `None` if the receiver was already taken. Prefer
137    /// [`resolve()`](Self::resolve) if you want the ordered event timeline;
138    /// this variant lets a consumer drain `event_tx` incrementally with
139    /// `recv()`.
140    ///
141    /// # Note
142    ///
143    /// Taking this stream *subscribes* to the event timeline, so the writer
144    /// will fan every step out to `event_tx`. As with any subscribed stream,
145    /// drain it concurrently (e.g. alongside [`text()`](Self::text)) so it
146    /// keeps up. Views you never take are simply skipped by the writer and can
147    /// never stall the stream.
148    pub fn take_event_stream(&mut self) -> Option<mpsc::Receiver<ResponseEvent>> {
149        self.subs.event.store(true, Ordering::Release);
150        self.rx.event.take()
151    }
152
153    /// Drain the text stream and return the complete response text.
154    ///
155    /// Consumes the handle — use the `take_*` methods instead if you need
156    /// to keep streaming individual channels.
157    ///
158    /// # Errors
159    ///
160    /// Returns a [`StreamError`] if the Python side reported an error.
161    pub async fn text(mut self) -> Result<ChatResult, StreamError> {
162        self.subs.text.store(true, Ordering::Release);
163        let mut buf = String::new();
164
165        if let Some(mut rx) = self.rx.text.take() {
166            while let Some(token) = rx.recv().await {
167                buf.push_str(&token);
168            }
169        }
170
171        // Check for errors. Use a brief timeout rather than try_recv() to
172        // catch errors that are sent just after the text channel closes.
173        if let Some(mut err_rx) = self.rx.error.take()
174            && let Ok(Some(err)) = tokio::time::timeout(ERROR_DRAIN_TIMEOUT, err_rx.recv()).await
175        {
176            return Err(err);
177        }
178
179        self.finalize();
180
181        Ok(ChatResult {
182            text: buf,
183            usage: self.usage,
184            structured_output: self.structured_output_value,
185        })
186    }
187
188    /// Finalize the response handle by pulling usage and structured output
189    /// from the shared state. Called after the stream has been fully drained.
190    pub fn finalize(&mut self) {
191        // NOLINT: Mutex::lock only fails if poisoned; else branch logs tracing::error!
192        if let Ok(state) = self.shared_state.lock() {
193            self.usage = state.usage.clone();
194            self.structured_output_value = state.structured_output.clone();
195        } else {
196            tracing::error!(
197                "ChatResponseHandle shared_state mutex poisoned during finalize — \
198                 usage and structured_output will be unavailable"
199            );
200        }
201    }
202
203    /// Return the structured output, if available.
204    ///
205    /// Only populated when the agent was configured with a `response_schema`
206    /// and the model returned a valid JSON payload.
207    #[must_use]
208    pub const fn structured_output(&self) -> Option<&serde_json::Value> {
209        self.structured_output_value.as_ref()
210    }
211
212    /// Return the token usage metadata, if available.
213    ///
214    /// Populated after [`finalize()`](Self::finalize) or [`text()`](Self::text).
215    #[must_use]
216    pub const fn usage_metadata(&self) -> Option<&UsageMetadata> {
217        self.usage.as_ref()
218    }
219
220    /// Return a reference-counted handle to the shared state.
221    ///
222    /// This allows callers to clone the `Arc` **before** consuming the handle
223    /// via [`text()`](Self::text) or [`resolve()`](Self::resolve), and then
224    /// read usage metadata / structured output from the shared state
225    /// afterwards.
226    #[doc(hidden)]
227    #[must_use]
228    pub fn shared_state(&self) -> Arc<Mutex<ChatResponseSharedState>> {
229        Arc::clone(&self.shared_state)
230    }
231
232    /// Drain all events and return them as an ordered timeline.
233    ///
234    /// Consumes the handle — use the `take_*` methods instead if you need
235    /// to keep streaming individual channels.
236    pub async fn resolve(mut self) -> Vec<ResponseEvent> {
237        self.subs.event.store(true, Ordering::Release);
238        let mut events = Vec::new();
239        if let Some(mut rx) = self.rx.event.take() {
240            while let Some(event) = rx.recv().await {
241                events.push(event);
242            }
243        }
244        self.finalize();
245        events
246    }
247}