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}