agy_bridge/streaming/writer.rs
1//! The sending/writing side of the streaming channel pair.
2
3use std::sync::{
4 Arc, Mutex,
5 atomic::{AtomicBool, Ordering},
6};
7
8use tokio::sync::mpsc;
9
10use super::types::{
11 ChatResponseSharedState, ResponseEvent, StreamChunk, StreamError, StreamSubscriptions,
12 ToolCallEvent,
13};
14use crate::types::Step;
15
16/// Error returned when sending to a [`ChatResponseWriter`] channel fails.
17///
18/// This wraps the underlying channel error to avoid leaking the
19/// `tokio::sync::mpsc::error::SendError<T>` generic into the public API.
20///
21/// # Example
22///
23/// ```
24/// use agy_bridge::streaming::WriterError;
25///
26/// let err = WriterError::new("receiver dropped");
27/// assert_eq!(err.to_string(), "receiver dropped");
28/// ```
29#[derive(Debug)]
30pub struct WriterError {
31 /// Human-readable description of the failure.
32 pub message: String,
33}
34
35impl WriterError {
36 /// Create a new writer error.
37 pub fn new(message: impl Into<String>) -> Self {
38 Self {
39 message: message.into(),
40 }
41 }
42}
43
44impl std::fmt::Display for WriterError {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 write!(f, "{}", self.message)
47 }
48}
49
50impl std::error::Error for WriterError {}
51
52impl<T> From<mpsc::error::SendError<T>> for WriterError {
53 fn from(err: mpsc::error::SendError<T>) -> Self {
54 Self {
55 message: format!("channel send failed: {err}"),
56 }
57 }
58}
59
60/// The sending side of a [`ChatResponseHandle`](super::handle::ChatResponseHandle),
61/// held by the Python bridge thread that drives the SDK's async iterator.
62pub struct ChatResponseWriter {
63 /// Sends text tokens.
64 pub(crate) text_tx: mpsc::Sender<String>,
65 /// Sends thinking tokens.
66 pub(crate) thought_tx: mpsc::Sender<String>,
67 /// Sends tool call events.
68 pub(crate) tool_call_tx: mpsc::Sender<ToolCallEvent>,
69 /// Sends a stream error (at most one).
70 pub(crate) error_tx: mpsc::Sender<StreamError>,
71 /// Sends ordered [`ResponseEvent`]s for the resolve timeline.
72 pub(crate) event_tx: mpsc::Sender<ResponseEvent>,
73 /// Sends [`Step`] objects as they are produced.
74 ///
75 /// The sender must be held to keep the channel alive for
76 /// [`ChatResponseHandle::take_step_stream()`](super::handle::ChatResponseHandle::take_step_stream).
77 /// It will be actively written once step-level streaming is wired through
78 /// the command loop.
79 pub(crate) step_tx: mpsc::Sender<Step>,
80 /// Sends unified [`StreamChunk`]s.
81 pub(crate) chunk_tx: mpsc::Sender<StreamChunk>,
82 /// Per-view subscription flags, shared with the handle.
83 ///
84 /// The writer consults these before fanning out so it never sends to a
85 /// channel the consumer isn't draining (which would block it forever).
86 pub(crate) subs: Arc<StreamSubscriptions>,
87 /// Shared state to send metadata updates back to the handle.
88 pub(crate) shared_state: Arc<Mutex<ChatResponseSharedState>>,
89}
90
91impl ChatResponseWriter {
92 /// Fan a streamed item out to a single optional "view" channel.
93 ///
94 /// This is the deadlock-safe primitive the bridge uses for its multi-channel
95 /// fan-out. It:
96 ///
97 /// * **Skips** the send entirely when no consumer has subscribed to this
98 /// view. Its receiver is never drained, so a real send would fill the
99 /// bounded buffer and block the writer forever — silently stalling the
100 /// whole stream. This is the root cause of the "no progress" hangs.
101 /// * Treats a **dropped receiver** mid-stream as an implicit unsubscribe:
102 /// it clears the flag and returns, so subsequent items skip immediately.
103 ///
104 /// A single view channel must never block or abort the entire stream.
105 pub(crate) async fn fan_out<T>(
106 subscribed: &AtomicBool,
107 tx: &mpsc::Sender<T>,
108 item: T,
109 channel: &'static str,
110 ) {
111 if !subscribed.load(Ordering::Acquire) {
112 return;
113 }
114 if let Err(e) = tx.send(item).await {
115 subscribed.store(false, Ordering::Release);
116 tracing::debug!(channel, error = %e, "fan-out receiver dropped; unsubscribing view");
117 }
118 }
119
120 /// Send a text token.
121 ///
122 /// # Errors
123 ///
124 /// Returns [`WriterError`] if the receiver has been dropped.
125 pub async fn send_text(&self, text: String) -> Result<(), WriterError> {
126 self.text_tx.send(text).await.map_err(WriterError::from)
127 }
128
129 /// Send a thinking token.
130 ///
131 /// # Errors
132 ///
133 /// Returns [`WriterError`] if the receiver has been dropped.
134 pub async fn send_thought(&self, thought: String) -> Result<(), WriterError> {
135 self.thought_tx
136 .send(thought)
137 .await
138 .map_err(WriterError::from)
139 }
140
141 /// Send a tool call event.
142 ///
143 /// # Errors
144 ///
145 /// Returns [`WriterError`] if the receiver has been dropped.
146 pub async fn send_tool_call(&self, event: ToolCallEvent) -> Result<(), WriterError> {
147 self.tool_call_tx
148 .send(event)
149 .await
150 .map_err(WriterError::from)
151 }
152
153 /// Send an error.
154 ///
155 /// # Errors
156 ///
157 /// Returns [`WriterError`] if the receiver has been dropped.
158 pub async fn send_error(&self, error: StreamError) -> Result<(), WriterError> {
159 self.error_tx.send(error).await.map_err(WriterError::from)
160 }
161
162 /// Send a response event.
163 ///
164 /// # Errors
165 ///
166 /// Returns [`WriterError`] if the receiver has been dropped.
167 pub async fn send_event(&self, event: ResponseEvent) -> Result<(), WriterError> {
168 self.event_tx.send(event).await.map_err(WriterError::from)
169 }
170
171 /// Send a step.
172 ///
173 /// # Errors
174 ///
175 /// Returns [`WriterError`] if the receiver has been dropped.
176 pub async fn send_step(&self, step: crate::types::Step) -> Result<(), WriterError> {
177 self.step_tx.send(step).await.map_err(WriterError::from)
178 }
179
180 /// Send a unified stream chunk.
181 ///
182 /// # Errors
183 ///
184 /// Returns [`WriterError`] if the receiver has been dropped.
185 pub async fn send_chunk(&self, chunk: StreamChunk) -> Result<(), WriterError> {
186 self.chunk_tx.send(chunk).await.map_err(WriterError::from)
187 }
188
189 /// Store usage metadata in the shared state so the handle can read it
190 /// after the stream completes.
191 pub fn set_usage(&self, usage: crate::types::UsageMetadata) {
192 match self.shared_state.lock() {
193 Ok(mut state) => {
194 state.usage = Some(usage);
195 }
196 Err(e) => {
197 tracing::error!(
198 error = %e,
199 "ChatResponseWriter shared_state mutex poisoned in set_usage"
200 );
201 }
202 }
203 }
204
205 /// Store structured output in the shared state so the handle can read it
206 /// after the stream completes.
207 pub fn set_structured_output(&self, value: serde_json::Value) {
208 match self.shared_state.lock() {
209 Ok(mut state) => {
210 state.structured_output = Some(value);
211 }
212 Err(e) => {
213 tracing::error!(
214 error = %e,
215 "ChatResponseWriter shared_state mutex poisoned in set_structured_output"
216 );
217 }
218 }
219 }
220}