Skip to main content

ai_agents_runtime/
streaming.rs

1use ai_agents_core::AgentResponse;
2use serde::{Deserialize, Serialize};
3
4/// Emits provisional stream chunks followed by one authoritative committed response.
5#[non_exhaustive]
6#[derive(Debug, Clone)]
7pub enum AgentStreamEvent {
8    /// Provisional content, tool, state, or error information emitted during execution.
9    Chunk(StreamChunk),
10    /// Authoritative response emitted after successful root-turn finalization.
11    Final(AgentResponse),
12}
13
14/// Represents a chunk of streamed response from the agent
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum StreamChunk {
18    /// Text content from the LLM
19    Content { text: String },
20    /// A tool call is starting
21    ToolCallStart { id: String, name: String },
22    /// Incremental arguments for a tool call
23    ToolCallDelta { id: String, arguments: String },
24    /// A tool call has completed
25    ToolCallEnd { id: String },
26    /// Tool execution result
27    ToolResult {
28        id: String,
29        name: String,
30        output: String,
31        success: bool,
32    },
33    /// State transition occurred
34    StateTransition { from: Option<String>, to: String },
35    /// Stream has completed
36    Done {},
37    /// An error occurred
38    Error { message: String },
39}
40
41impl StreamChunk {
42    pub fn content(text: impl Into<String>) -> Self {
43        StreamChunk::Content { text: text.into() }
44    }
45
46    pub fn tool_start(id: impl Into<String>, name: impl Into<String>) -> Self {
47        StreamChunk::ToolCallStart {
48            id: id.into(),
49            name: name.into(),
50        }
51    }
52
53    pub fn tool_delta(id: impl Into<String>, arguments: impl Into<String>) -> Self {
54        StreamChunk::ToolCallDelta {
55            id: id.into(),
56            arguments: arguments.into(),
57        }
58    }
59
60    pub fn tool_end(id: impl Into<String>) -> Self {
61        StreamChunk::ToolCallEnd { id: id.into() }
62    }
63
64    pub fn tool_result(
65        id: impl Into<String>,
66        name: impl Into<String>,
67        output: impl Into<String>,
68        success: bool,
69    ) -> Self {
70        StreamChunk::ToolResult {
71            id: id.into(),
72            name: name.into(),
73            output: output.into(),
74            success,
75        }
76    }
77
78    pub fn state_transition(from: Option<String>, to: impl Into<String>) -> Self {
79        StreamChunk::StateTransition {
80            from,
81            to: to.into(),
82        }
83    }
84
85    pub fn error(message: impl Into<String>) -> Self {
86        StreamChunk::Error {
87            message: message.into(),
88        }
89    }
90
91    pub fn is_done(&self) -> bool {
92        matches!(self, StreamChunk::Done {})
93    }
94
95    pub fn is_error(&self) -> bool {
96        matches!(self, StreamChunk::Error { .. })
97    }
98
99    pub fn is_content(&self) -> bool {
100        matches!(self, StreamChunk::Content { .. })
101    }
102}
103
104/// Configuration for streaming behavior
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct StreamingConfig {
107    /// Whether streaming is enabled
108    #[serde(default = "default_true")]
109    pub enabled: bool,
110    /// Buffer size for streaming chunks
111    #[serde(default = "default_buffer_size")]
112    pub buffer_size: usize,
113    /// Include tool call events in the stream
114    #[serde(default = "default_true")]
115    pub include_tool_events: bool,
116    /// Include state transition events in the stream
117    #[serde(default = "default_true")]
118    pub include_state_events: bool,
119}
120
121fn default_true() -> bool {
122    true
123}
124
125fn default_buffer_size() -> usize {
126    256
127}
128
129impl Default for StreamingConfig {
130    fn default() -> Self {
131        Self {
132            enabled: true,
133            buffer_size: default_buffer_size(),
134            include_tool_events: true,
135            include_state_events: true,
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_agent_stream_event_carries_chunks_and_final_response() {
146        let chunk = AgentStreamEvent::Chunk(StreamChunk::content("Hello"));
147        assert!(matches!(
148            chunk,
149            AgentStreamEvent::Chunk(StreamChunk::Content { .. })
150        ));
151
152        let final_response = AgentStreamEvent::Final(AgentResponse::new("Hello"));
153        assert!(
154            matches!(final_response, AgentStreamEvent::Final(response) if response.content == "Hello")
155        );
156    }
157
158    #[test]
159    fn test_stream_chunk_constructors() {
160        let content = StreamChunk::content("Hello");
161        assert!(content.is_content());
162
163        let tool_start = StreamChunk::tool_start("id1", "calculator");
164        assert!(matches!(tool_start, StreamChunk::ToolCallStart { .. }));
165
166        let tool_delta = StreamChunk::tool_delta("id1", r#"{"expr":"1+1"}"#);
167        assert!(matches!(tool_delta, StreamChunk::ToolCallDelta { .. }));
168
169        let tool_end = StreamChunk::tool_end("id1");
170        assert!(matches!(tool_end, StreamChunk::ToolCallEnd { .. }));
171
172        let done = StreamChunk::Done {};
173        assert!(done.is_done());
174
175        let error = StreamChunk::error("Something went wrong");
176        assert!(error.is_error());
177    }
178
179    #[test]
180    fn test_stream_chunk_serialization() {
181        let content = StreamChunk::content("Hello");
182        let json = serde_json::to_string(&content).unwrap();
183        assert!(json.contains("content"));
184        assert!(json.contains("Hello"));
185
186        let tool_start = StreamChunk::tool_start("id1", "calculator");
187        let json = serde_json::to_string(&tool_start).unwrap();
188        assert!(json.contains("tool_call_start"));
189        assert!(json.contains("calculator"));
190
191        assert_eq!(
192            serde_json::to_string(&StreamChunk::Done {}).unwrap(),
193            r#"{"type":"done"}"#
194        );
195    }
196
197    #[test]
198    fn test_streaming_config_defaults() {
199        let config = StreamingConfig::default();
200        assert!(config.enabled);
201        assert_eq!(config.buffer_size, 256);
202        assert!(config.include_tool_events);
203        assert!(config.include_state_events);
204    }
205
206    #[test]
207    fn test_streaming_config_deserialization() {
208        let yaml = r#"
209enabled: true
210buffer_size: 64
211include_tool_events: false
212"#;
213        let config: StreamingConfig = serde_yaml::from_str(yaml).unwrap();
214        assert!(config.enabled);
215        assert_eq!(config.buffer_size, 64);
216        assert!(!config.include_tool_events);
217        assert!(config.include_state_events);
218    }
219
220    #[test]
221    fn test_tool_result_chunk() {
222        let result = StreamChunk::tool_result("id1", "calculator", "42", true);
223        match result {
224            StreamChunk::ToolResult {
225                id,
226                name,
227                output,
228                success,
229            } => {
230                assert_eq!(id, "id1");
231                assert_eq!(name, "calculator");
232                assert_eq!(output, "42");
233                assert!(success);
234            }
235            _ => panic!("Expected ToolResult"),
236        }
237    }
238
239    #[test]
240    fn test_state_transition_chunk() {
241        let transition = StreamChunk::state_transition(Some("greeting".to_string()), "support");
242        match transition {
243            StreamChunk::StateTransition { from, to } => {
244                assert_eq!(from, Some("greeting".to_string()));
245                assert_eq!(to, "support");
246            }
247            _ => panic!("Expected StateTransition"),
248        }
249    }
250
251    #[test]
252    fn test_stream_chunk_done_serialization() {
253        let done = StreamChunk::Done {};
254        let json = serde_json::to_string(&done).unwrap();
255        assert!(json.contains("done"));
256    }
257
258    #[test]
259    fn test_stream_chunk_error_serialization() {
260        let error = StreamChunk::error("Test error");
261        let json = serde_json::to_string(&error).unwrap();
262        assert!(json.contains("error"));
263        assert!(json.contains("Test error"));
264    }
265
266    #[test]
267    fn test_stream_chunk_tool_result_serialization() {
268        let result = StreamChunk::tool_result("id1", "calculator", "42", true);
269        let json = serde_json::to_string(&result).unwrap();
270        assert!(json.contains("tool_result"));
271        assert!(json.contains("calculator"));
272        assert!(json.contains("42"));
273        assert!(json.contains("true"));
274    }
275
276    #[test]
277    fn test_streaming_config_full_yaml() {
278        let yaml = r#"
279enabled: false
280buffer_size: 128
281include_tool_events: false
282include_state_events: false
283"#;
284        let config: StreamingConfig = serde_yaml::from_str(yaml).unwrap();
285        assert!(!config.enabled);
286        assert_eq!(config.buffer_size, 128);
287        assert!(!config.include_tool_events);
288        assert!(!config.include_state_events);
289    }
290
291    #[test]
292    fn test_stream_chunk_deserialization() {
293        let json = r#"{"type":"content","text":"Hello"}"#;
294        let chunk: StreamChunk = serde_json::from_str(json).unwrap();
295        assert!(chunk.is_content());
296
297        let json = r#"{"type":"done"}"#;
298        let chunk: StreamChunk = serde_json::from_str(json).unwrap();
299        assert!(chunk.is_done());
300
301        let json = r#"{"type":"error","message":"fail"}"#;
302        let chunk: StreamChunk = serde_json::from_str(json).unwrap();
303        assert!(chunk.is_error());
304    }
305
306    #[test]
307    fn test_stream_chunk_tool_events() {
308        let start = StreamChunk::tool_start("tool-1", "http");
309        let delta = StreamChunk::tool_delta("tool-1", r#"{"url":"test"}"#);
310        let end = StreamChunk::tool_end("tool-1");
311
312        match start {
313            StreamChunk::ToolCallStart { id, name } => {
314                assert_eq!(id, "tool-1");
315                assert_eq!(name, "http");
316            }
317            _ => panic!("Expected ToolCallStart"),
318        }
319
320        match delta {
321            StreamChunk::ToolCallDelta { id, arguments } => {
322                assert_eq!(id, "tool-1");
323                assert!(arguments.contains("url"));
324            }
325            _ => panic!("Expected ToolCallDelta"),
326        }
327
328        match end {
329            StreamChunk::ToolCallEnd { id } => {
330                assert_eq!(id, "tool-1");
331            }
332            _ => panic!("Expected ToolCallEnd"),
333        }
334    }
335}