Skip to main content

agy_bridge/streaming/
types.rs

1//! Streaming types: chunks, events, errors, and shared state.
2
3use std::{sync::atomic::AtomicBool, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use tokio::sync::mpsc;
7
8use crate::types::{Step, UsageMetadata};
9
10/// The result of draining a chat response via [`super::ChatResponseHandle::text()`].
11///
12/// Carries the full response text alongside optional metadata (token usage,
13/// structured output). Dereferences to `&str` for ergonomic use:
14///
15/// ```rust
16/// # #[tokio::main]
17/// # async fn main() -> Result<(), agy_bridge::error::Error> {
18/// # agy_bridge::load_dotenv();
19/// # let bridge = agy_bridge::AgyBridge::builder().build()?;
20/// # let agent = bridge.agent(
21/// #     agy_bridge::config::AgentConfig::builder()
22/// #         .system_instructions("Reply with 'Hello!' and nothing else. Never use tools.")
23/// #         .capabilities(agy_bridge::config::CapabilitiesConfig::custom_tools_only())
24/// #         .build()
25/// # ).await?;
26/// let result = agent
27///     .chat("Reply with 'Hello!' and nothing else.")
28///     .await?
29///     .text()
30///     .await?;
31/// println!("{result}"); // prints text
32/// if let Some(usage) = result.usage() { /* access metadata */ }
33/// # agent.shutdown().await?;
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Debug, Clone)]
38pub struct ChatResult {
39    pub(super) text: String,
40    pub(super) usage: Option<UsageMetadata>,
41    pub(super) structured_output: Option<serde_json::Value>,
42}
43
44impl ChatResult {
45    /// The full response text.
46    #[must_use]
47    pub fn text(&self) -> &str {
48        &self.text
49    }
50
51    /// Consume the result and return the inner `String`.
52    #[must_use]
53    pub fn into_string(self) -> String {
54        self.text
55    }
56
57    /// Token usage metadata, if available.
58    #[must_use]
59    pub fn usage(&self) -> Option<&UsageMetadata> {
60        self.usage.as_ref()
61    }
62
63    /// Structured output (JSON), if the agent was configured with a
64    /// `response_schema` and the model returned valid JSON.
65    #[must_use]
66    pub fn structured_output(&self) -> Option<&serde_json::Value> {
67        self.structured_output.as_ref()
68    }
69}
70
71impl std::ops::Deref for ChatResult {
72    type Target = str;
73    fn deref(&self) -> &str {
74        &self.text
75    }
76}
77
78impl PartialEq<&str> for ChatResult {
79    fn eq(&self, other: &&str) -> bool {
80        self.text == *other
81    }
82}
83
84impl PartialEq<String> for ChatResult {
85    fn eq(&self, other: &String) -> bool {
86        self.text == *other
87    }
88}
89
90impl std::fmt::Display for ChatResult {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.write_str(&self.text)
93    }
94}
95
96impl From<ChatResult> for String {
97    fn from(result: ChatResult) -> Self {
98        result.text
99    }
100}
101
102/// Brief timeout used when draining the error channel after the text stream
103/// closes. Shared with [`crate::interactive`].
104pub(crate) const ERROR_DRAIN_TIMEOUT: Duration = Duration::from_millis(50);
105
106/// A tool call event received during streaming.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ToolCallEvent {
109    /// Tool name (e.g. `"view_file"` or a custom tool name).
110    pub name: String,
111    /// Arguments as a JSON object.
112    pub args: serde_json::Value,
113    /// Optional call identifier assigned by the backend.
114    pub id: Option<String>,
115    /// Optional canonical path for file tools.
116    #[serde(default)]
117    pub canonical_path: Option<String>,
118}
119
120/// Error sent over the error channel when the Python stream fails.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct StreamError {
123    /// Error message from the Python side.
124    pub message: String,
125    /// HTTP status code associated with the failure, when the harness reported
126    /// one on the error step (`0` when unknown, e.g. a Python-level exception).
127    ///
128    /// This is the authoritative classification signal used by
129    /// [`crate::error::Error::is_quota_error`] and
130    /// [`crate::error::Error::is_retryable`]; the message string is only a
131    /// fallback for errors that carry no structured code.
132    #[serde(default)]
133    pub http_code: u16,
134}
135
136impl StreamError {
137    /// Create a stream error with an unknown (`0`) HTTP status code.
138    #[must_use]
139    pub fn new(message: impl Into<String>) -> Self {
140        Self {
141            message: message.into(),
142            http_code: crate::error::HTTP_CODE_UNKNOWN,
143        }
144    }
145
146    /// Create a stream error carrying the harness-reported HTTP status code.
147    #[must_use]
148    pub fn with_http_code(message: impl Into<String>, http_code: u16) -> Self {
149        Self {
150            message: message.into(),
151            http_code,
152        }
153    }
154}
155
156impl std::fmt::Display for StreamError {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        write!(f, "stream error: {}", self.message)
159    }
160}
161
162impl std::error::Error for StreamError {}
163
164/// An ordered event from a response timeline, produced by [`super::ChatResponseHandle::resolve`].
165///
166/// Mirrors the Python SDK's `ChatResponse.resolve()` which returns
167/// `list[StreamChunk | ToolCall | ToolResult]`.
168#[non_exhaustive]
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub enum ResponseEvent {
171    /// A text chunk from the model.
172    TextChunk(String),
173    /// A thinking/reasoning chunk from the model.
174    ThoughtChunk(String),
175    /// A tool call request from the model.
176    ToolCall(ToolCallEvent),
177    /// A tool execution result.
178    ToolResult(crate::types::ToolResult),
179}
180
181/// A chunk from the streaming response, combining text, thought, and tool call events.
182///
183/// This provides a unified stream of all chunk types, unlike the separate
184/// `take_text_stream()` / `take_thought_stream()` / `take_tool_call_stream()`
185/// accessors which split events by kind.
186#[non_exhaustive]
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub enum StreamChunk {
189    /// A text token from the model.
190    Text(String),
191    /// A thinking/reasoning token.
192    Thought(String),
193    /// A tool call event.
194    ToolCall(ToolCallEvent),
195}
196
197/// Shared mutable state between the writer and handle.
198///
199/// Uses `std::sync::Mutex` rather than `tokio::sync::Mutex` because the lock
200/// is held only for brief field reads/clones (never across `.await`). This is
201/// safe from deadlocks and cheaper than an async mutex.
202#[doc(hidden)]
203#[derive(Debug, Default)]
204pub struct ChatResponseSharedState {
205    /// Token usage metadata, populated by the writer after the stream completes.
206    pub usage: Option<UsageMetadata>,
207    /// Structured output, populated by the writer after the stream completes.
208    pub structured_output: Option<serde_json::Value>,
209}
210
211/// Tracks which streaming "views" a consumer has subscribed to.
212///
213/// The bridge fans every step out to several independent channels (text,
214/// thought, tool-call, the ordered `event` timeline, the unified `chunk`
215/// stream, and the raw `step` stream). A given consumer typically attaches to
216/// only a subset — e.g. a CLI wants text only, while an orchestrator wants
217/// text + thought + step + tool-call.
218///
219/// Sending to a channel whose receiver is **never drained** fills its bounded
220/// buffer and then blocks the writer *forever*, silently stalling the entire
221/// stream. To make every consumption pattern deadlock-free, each handle
222/// accessor marks its channel as subscribed, and the writer skips fan-out to
223/// any unsubscribed channel. Channels that *are* consumed still receive every
224/// item (no data loss); only channels nobody listens to are skipped.
225///
226/// The `error` channel is intentionally omitted: it has capacity 1 and is sent
227/// with `try_send` (never blocks), so it needs no gating.
228#[derive(Debug, Default)]
229pub(crate) struct StreamSubscriptions {
230    /// A consumer is draining the text-token stream.
231    pub text: AtomicBool,
232    /// A consumer is draining the thinking-token stream.
233    pub thought: AtomicBool,
234    /// A consumer is draining the tool-call stream.
235    pub tool_call: AtomicBool,
236    /// A consumer is draining the ordered event timeline.
237    pub event: AtomicBool,
238    /// A consumer is draining the raw step stream.
239    pub step: AtomicBool,
240    /// A consumer is draining the unified chunk stream.
241    pub chunk: AtomicBool,
242}
243
244/// Grouped receivers for each independent stream channel.
245///
246/// Extracted from [`ChatResponseHandle`] so the seven channel receivers
247/// are logically grouped, keeping the handle's field list manageable.
248#[derive(Debug)]
249pub(crate) struct StreamReceivers {
250    /// Receives text tokens as they arrive from the model.
251    pub(super) text: Option<mpsc::Receiver<String>>,
252    /// Receives thinking/reasoning tokens.
253    pub(super) thought: Option<mpsc::Receiver<String>>,
254    /// Receives tool call events.
255    pub(super) tool_call: Option<mpsc::Receiver<ToolCallEvent>>,
256    /// Receives at most one error if the stream fails.
257    pub(super) error: Option<mpsc::Receiver<StreamError>>,
258    /// Receives ordered [`ResponseEvent`]s for [`resolve()`](super::handle::ChatResponseHandle::resolve).
259    pub(super) event: Option<mpsc::Receiver<ResponseEvent>>,
260    /// Receives [`Step`] objects as they are produced.
261    pub(super) step: Option<mpsc::Receiver<Step>>,
262    /// Receives unified [`StreamChunk`]s (text, thought, and tool call events).
263    pub(super) chunk: Option<mpsc::Receiver<StreamChunk>>,
264}
265
266impl StreamReceivers {
267    /// Create a new set of receivers from channel endpoints.
268    pub(super) fn new(
269        text: mpsc::Receiver<String>,
270        thought: mpsc::Receiver<String>,
271        tool_call: mpsc::Receiver<ToolCallEvent>,
272        error: mpsc::Receiver<StreamError>,
273        event: mpsc::Receiver<ResponseEvent>,
274        step: mpsc::Receiver<Step>,
275        chunk: mpsc::Receiver<StreamChunk>,
276    ) -> Self {
277        Self {
278            text: Some(text),
279            thought: Some(thought),
280            tool_call: Some(tool_call),
281            error: Some(error),
282            event: Some(event),
283            step: Some(step),
284            chunk: Some(chunk),
285        }
286    }
287}