Skip to main content

open_agent/types/
stream_event.rs

1//! Items yielded by the model stream: content, reasoning, and the terminating finish reason.
2
3use super::ContentBlock;
4
5/// Why the model stopped generating.
6///
7/// OpenAI-compatible servers report this as a `finish_reason` string on the final streaming
8/// chunk. The SDK maps the well-known values onto variants and preserves anything else
9/// verbatim in [`FinishReason::Other`], so a provider-specific reason is never silently
10/// flattened into a generic one.
11///
12/// # Why `Unspecified` exists
13///
14/// `finish_reason` is optional in practice. llama.cpp, vLLM, and several local gateways stream
15/// content and then close the connection (or send `data: [DONE]`) with `finish_reason` still
16/// null. Reporting that as [`FinishReason::Stop`] would claim the model finished cleanly when
17/// the SDK has no evidence either way, so it is reported as
18/// [`FinishReason::Unspecified`] instead — a distinct, checkable state.
19///
20/// # Examples
21///
22/// The distinction that matters for callers parsing structured output:
23///
24/// ```rust
25/// use open_agent::FinishReason;
26///
27/// // A truncated response is worth retrying with a larger budget.
28/// assert!(FinishReason::Length.is_truncated());
29/// // A clean stop that produced unparseable output is a model behaviour problem.
30/// assert!(!FinishReason::Stop.is_truncated());
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum FinishReason {
35    /// Generation completed naturally (`"stop"`).
36    Stop,
37
38    /// Generation was cut off at the token limit (`"length"`).
39    ///
40    /// The content delivered before this point is a prefix of what the model intended to say.
41    Length,
42
43    /// The model finished in order to call tools (`"tool_calls"`).
44    ToolCalls,
45
46    /// Generation was halted by a content filter (`"content_filter"`).
47    ContentFilter,
48
49    /// A reason the SDK does not recognise, preserved exactly as the server sent it.
50    Other(String),
51
52    /// The SDK's automatic tool-execution loop stopped at `max_tool_iterations`.
53    ///
54    /// Unlike every other variant this does not come from the server: generation was cut
55    /// short by the client, and the model's own `finish_reason` for the last round would
56    /// have been `ToolCalls` — an accurate answer to a different question than "why did this
57    /// operation stop?". Reported only by
58    /// [`Client::finish_reason()`](crate::Client::finish_reason) in auto-execution mode; it
59    /// never appears in a [`StreamEvent::Finish`], and [`FinishReason::from_wire`] never
60    /// produces it.
61    MaxToolIterations,
62
63    /// The stream ended without the server ever reporting a reason.
64    ///
65    /// This is not an error — it is the normal behaviour of several OpenAI-compatible
66    /// servers. It means "no information", which is deliberately distinct from
67    /// [`FinishReason::Stop`].
68    Unspecified,
69}
70
71impl FinishReason {
72    /// Maps a raw `finish_reason` string from the wire onto a variant.
73    ///
74    /// Matching is ASCII-case-insensitive because servers are inconsistent about casing.
75    /// Unrecognised values are preserved verbatim (original casing intact) in
76    /// [`FinishReason::Other`].
77    ///
78    /// # Examples
79    ///
80    /// ```rust
81    /// use open_agent::FinishReason;
82    ///
83    /// assert_eq!(FinishReason::from_wire("length"), FinishReason::Length);
84    /// assert_eq!(
85    ///     FinishReason::from_wire("ERR"),
86    ///     FinishReason::Other("ERR".to_string())
87    /// );
88    /// ```
89    pub fn from_wire(raw: &str) -> Self {
90        match raw.to_ascii_lowercase().as_str() {
91            "stop" => Self::Stop,
92            "length" => Self::Length,
93            "tool_calls" => Self::ToolCalls,
94            "content_filter" => Self::ContentFilter,
95            _ => Self::Other(raw.to_string()),
96        }
97    }
98
99    /// Returns the canonical wire string for this reason.
100    ///
101    /// [`FinishReason::Unspecified`] has no wire representation — it is reported as
102    /// `"unspecified"` so it can be logged without being mistaken for `"stop"`.
103    pub fn as_str(&self) -> &str {
104        match self {
105            Self::Stop => "stop",
106            Self::Length => "length",
107            Self::ToolCalls => "tool_calls",
108            Self::ContentFilter => "content_filter",
109            Self::Other(raw) => raw,
110            Self::MaxToolIterations => "max_tool_iterations",
111            Self::Unspecified => "unspecified",
112        }
113    }
114
115    /// Returns `true` when generation was cut short by the token limit.
116    ///
117    /// This is the signal that a partial or unparseable response is the SDK caller's budget
118    /// problem rather than a model that refused to answer in the requested format.
119    pub fn is_truncated(&self) -> bool {
120        matches!(self, Self::Length)
121    }
122}
123
124impl std::fmt::Display for FinishReason {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(self.as_str())
127    }
128}
129
130/// One item in the stream returned by [`query()`](crate::query).
131///
132/// Before 0.8.0 the stream yielded bare [`ContentBlock`]s, which left no room for anything
133/// that is not content — most importantly the reason generation stopped. `StreamEvent` makes
134/// that explicit: content arrives as [`StreamEvent::Block`], and the stream always ends with
135/// exactly one [`StreamEvent::Finish`].
136///
137/// # Guarantees
138///
139/// - Exactly one [`StreamEvent::Finish`] is emitted per stream, and it is the final event.
140/// - [`StreamEvent::Reasoning`] is emitted only when
141///   [`AgentOptions::include_reasoning`](crate::AgentOptions::include_reasoning) is enabled,
142///   and never carries text that also appears in a [`ContentBlock::Text`].
143///
144/// The enum is `#[non_exhaustive]`: future channels can be added without another breaking
145/// release, so match with a `_` arm.
146///
147/// # Examples
148///
149/// ```rust,no_run
150/// use futures::StreamExt;
151/// use open_agent::{AgentOptions, ContentBlock, FinishReason, StreamEvent, query};
152///
153/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
154/// let options = AgentOptions::builder()
155///     .model("deepseek-reasoner")
156///     .base_url("http://localhost:1234/v1")
157///     .build()?;
158///
159/// let mut answer = String::new();
160/// let mut stream = query("Reply with JSON.", &options).await?;
161///
162/// while let Some(event) = stream.next().await {
163///     match event? {
164///         StreamEvent::Block(ContentBlock::Text(text)) => answer.push_str(&text.text),
165///         StreamEvent::Finish(FinishReason::Length) => {
166///             // Truncated at the token cap: retry with a larger budget rather than
167///             // treating the unparseable body as a refusal.
168///         }
169///         _ => {}
170///     }
171/// }
172/// # Ok(())
173/// # }
174/// ```
175#[derive(Debug, Clone)]
176#[non_exhaustive]
177pub enum StreamEvent {
178    /// One content fragment: a piece of assistant text as it arrives, or a fully assembled
179    /// tool call.
180    ///
181    /// Text arrives split across as many events as the server sent deltas, in order. Join
182    /// them to reconstruct the answer.
183    Block(ContentBlock),
184
185    /// One fragment of reasoning/chain-of-thought text from the model's side channel.
186    ///
187    /// Opt in with
188    /// [`AgentOptions::builder().include_reasoning(true)`](crate::AgentOptionsBuilder::include_reasoning).
189    /// This text is never merged into a [`ContentBlock::Text`] and never enters conversation
190    /// history.
191    Reasoning(String),
192
193    /// The stream has ended; carries why generation stopped.
194    Finish(FinishReason),
195}
196
197impl StreamEvent {
198    /// Returns the content block if this event carries one.
199    pub fn as_block(&self) -> Option<&ContentBlock> {
200        match self {
201            Self::Block(block) => Some(block),
202            _ => None,
203        }
204    }
205
206    /// Consumes the event, returning its content block if it carries one.
207    ///
208    /// This is the shortest migration path from the pre-0.8.0 block stream:
209    ///
210    /// ```rust,no_run
211    /// # use futures::StreamExt;
212    /// # use open_agent::{AgentOptions, ContentBlock, query};
213    /// # async fn example(options: AgentOptions) -> Result<(), Box<dyn std::error::Error>> {
214    /// let mut stream = query("hi", &options).await?;
215    /// while let Some(event) = stream.next().await {
216    ///     if let Some(ContentBlock::Text(text)) = event?.into_block() {
217    ///         print!("{}", text.text);
218    ///     }
219    /// }
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub fn into_block(self) -> Option<ContentBlock> {
224        match self {
225            Self::Block(block) => Some(block),
226            _ => None,
227        }
228    }
229
230    /// Returns the text if this event carries a [`ContentBlock::Text`].
231    pub fn as_text(&self) -> Option<&str> {
232        match self {
233            Self::Block(ContentBlock::Text(text)) => Some(&text.text),
234            _ => None,
235        }
236    }
237
238    /// Returns the reasoning text if this event carries one.
239    pub fn as_reasoning(&self) -> Option<&str> {
240        match self {
241            Self::Reasoning(reasoning) => Some(reasoning),
242            _ => None,
243        }
244    }
245
246    /// Returns the finish reason if this is the terminating event.
247    pub fn finish_reason(&self) -> Option<&FinishReason> {
248        match self {
249            Self::Finish(reason) => Some(reason),
250            _ => None,
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::types::{TextBlock, ToolUseBlock};
259
260    include!("tests/stream_event.rs");
261}