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 /// A completed content block: assistant text, or a fully assembled tool call.
179 Block(ContentBlock),
180
181 /// Accumulated reasoning/chain-of-thought text from the model's side channel.
182 ///
183 /// Opt in with
184 /// [`AgentOptions::builder().include_reasoning(true)`](crate::AgentOptionsBuilder::include_reasoning).
185 /// This text is never merged into a [`ContentBlock::Text`] and never enters conversation
186 /// history.
187 Reasoning(String),
188
189 /// The stream has ended; carries why generation stopped.
190 Finish(FinishReason),
191}
192
193impl StreamEvent {
194 /// Returns the content block if this event carries one.
195 pub fn as_block(&self) -> Option<&ContentBlock> {
196 match self {
197 Self::Block(block) => Some(block),
198 _ => None,
199 }
200 }
201
202 /// Consumes the event, returning its content block if it carries one.
203 ///
204 /// This is the shortest migration path from the pre-0.8.0 block stream:
205 ///
206 /// ```rust,no_run
207 /// # use futures::StreamExt;
208 /// # use open_agent::{AgentOptions, ContentBlock, query};
209 /// # async fn example(options: AgentOptions) -> Result<(), Box<dyn std::error::Error>> {
210 /// let mut stream = query("hi", &options).await?;
211 /// while let Some(event) = stream.next().await {
212 /// if let Some(ContentBlock::Text(text)) = event?.into_block() {
213 /// print!("{}", text.text);
214 /// }
215 /// }
216 /// # Ok(())
217 /// # }
218 /// ```
219 pub fn into_block(self) -> Option<ContentBlock> {
220 match self {
221 Self::Block(block) => Some(block),
222 _ => None,
223 }
224 }
225
226 /// Returns the text if this event carries a [`ContentBlock::Text`].
227 pub fn as_text(&self) -> Option<&str> {
228 match self {
229 Self::Block(ContentBlock::Text(text)) => Some(&text.text),
230 _ => None,
231 }
232 }
233
234 /// Returns the reasoning text if this event carries one.
235 pub fn as_reasoning(&self) -> Option<&str> {
236 match self {
237 Self::Reasoning(reasoning) => Some(reasoning),
238 _ => None,
239 }
240 }
241
242 /// Returns the finish reason if this is the terminating event.
243 pub fn finish_reason(&self) -> Option<&FinishReason> {
244 match self {
245 Self::Finish(reason) => Some(reason),
246 _ => None,
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::types::{TextBlock, ToolUseBlock};
255
256 include!("tests/stream_event.rs");
257}