bamboo_engine/runtime/stream/
handler.rs1use std::future::Future;
2use std::time::Duration;
3
4use tokio::sync::mpsc;
5use tokio::time::Instant;
6use tokio_util::sync::CancellationToken;
7
8use bamboo_agent_core::tools::ToolCall;
9use bamboo_agent_core::{AgentError, AgentEvent, StreamTimeoutError, StreamTimeoutPhase};
10use bamboo_config::StreamTimeoutConfig;
11use bamboo_llm::LLMStream;
12
13mod chunk_handling;
14mod consume;
15mod stream_state;
16
17#[derive(Debug, Clone)]
21pub struct StreamTimeoutContext {
22 pub(crate) policy: StreamTimeoutConfig,
23 pub(crate) provider: Option<String>,
24 pub(crate) model: Option<String>,
25 request_started_at: Option<Instant>,
26 turn_retry_eligible: bool,
27}
28
29impl StreamTimeoutContext {
30 pub fn new(policy: StreamTimeoutConfig, provider: Option<&str>, model: Option<&str>) -> Self {
31 let policy = match policy.validate() {
32 Ok(()) => policy,
33 Err(error) => {
34 tracing::warn!(
35 "invalid programmatic stream timeout policy ({error}); using safe defaults"
36 );
37 StreamTimeoutConfig::default()
38 }
39 };
40 Self {
41 policy,
42 provider: provider.and_then(sanitize_identifier),
43 model: model.and_then(sanitize_identifier),
44 request_started_at: None,
45 turn_retry_eligible: false,
46 }
47 }
48
49 pub(crate) fn allow_turn_retry_before_semantic_output(mut self) -> Self {
53 self.turn_retry_eligible = true;
54 self
55 }
56
57 pub(crate) fn begin_request(mut self) -> Self {
60 self.request_started_at = Some(Instant::now());
61 self
62 }
63
64 fn timeout_error(
65 &self,
66 session_id: &str,
67 phase: StreamTimeoutPhase,
68 deadline: Duration,
69 last_transport: Duration,
70 last_semantic: Option<Duration>,
71 ) -> AgentError {
72 let timeout = StreamTimeoutError::new(
73 phase,
74 deadline,
75 self.provider.clone(),
76 self.model.clone(),
77 last_transport,
78 last_semantic,
79 self.turn_retry_eligible,
80 );
81 tracing::warn!("[{}] LLM stream watchdog expired: {}", session_id, timeout,);
82 AgentError::StreamTimeout(timeout)
83 }
84}
85
86impl Default for StreamTimeoutContext {
87 fn default() -> Self {
88 Self::new(StreamTimeoutConfig::default(), None, None)
89 }
90}
91
92pub(crate) async fn await_stream_bootstrap<F, T>(
100 future: F,
101 cancel_token: &CancellationToken,
102 session_id: &str,
103 timeout_context: &StreamTimeoutContext,
104) -> Result<T, AgentError>
105where
106 F: Future<Output = T>,
107{
108 let started_at = timeout_context
109 .request_started_at
110 .unwrap_or_else(Instant::now);
111 let deadline = Duration::from_secs(timeout_context.policy.transport_idle_timeout_secs);
112 let expires_at = started_at + deadline;
113
114 tokio::select! {
115 biased;
116 _ = cancel_token.cancelled() => Err(AgentError::Cancelled),
117 result = future => Ok(result),
118 _ = tokio::time::sleep_until(expires_at) => {
119 let now = Instant::now();
120 Err(timeout_context.timeout_error(
121 session_id,
122 StreamTimeoutPhase::Bootstrap,
123 deadline,
124 now.saturating_duration_since(started_at),
125 None,
126 ))
127 }
128 }
129}
130
131fn sanitize_identifier(value: &str) -> Option<String> {
132 let value = value.trim();
133 if value.is_empty() {
134 return None;
135 }
136 let sanitized: String = value
137 .chars()
138 .take(120)
139 .map(|character| match character {
140 character if character.is_ascii_alphanumeric() => character,
141 '-' | '_' | '.' | ':' | '/' | '@' => character,
142 _ => '_',
143 })
144 .collect();
145 (!sanitized.is_empty()).then_some(sanitized)
146}
147
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
154pub struct ProviderUsageSnapshot {
155 pub input_tokens: Option<u64>,
156 pub output_tokens: Option<u64>,
157 pub total_tokens: Option<u64>,
158 pub reasoning_tokens: Option<u64>,
159 pub cache_creation_input_tokens: Option<u64>,
160 pub cache_read_input_tokens: Option<u64>,
161 pub cache_write_input_tokens: Option<u64>,
164}
165
166pub struct StreamHandlingOutput {
167 pub response_id: Option<String>,
168 pub content: String,
169 pub reasoning_content: String,
170 pub reasoning_signature: Option<String>,
174 pub token_count: usize,
175 pub tool_calls: Vec<ToolCall>,
176 pub output_tokens: u64,
177 pub thinking_tokens: u64,
178 pub cache_creation_input_tokens: u64,
179 pub cache_read_input_tokens: u64,
180 pub provider_usage: Option<ProviderUsageSnapshot>,
184 pub input_tokens: u64,
189}
190
191#[derive(Debug, Clone, serde::Serialize)]
192pub(crate) struct PartialToolCallSnapshot {
193 pub id: String,
194 pub tool_type: String,
195 pub name: String,
196 pub arguments: String,
197 pub index: Option<u32>,
198}
199
200pub(crate) struct InterruptedStreamOutput {
205 pub content: String,
206 pub reasoning_content: String,
207 pub partial_tool_calls: Vec<PartialToolCallSnapshot>,
208}
209
210impl From<&bamboo_agent_core::tools::PartialToolCall> for PartialToolCallSnapshot {
211 fn from(value: &bamboo_agent_core::tools::PartialToolCall) -> Self {
212 Self {
213 id: value.id.clone(),
214 tool_type: value.tool_type.clone(),
215 name: value.name.clone(),
216 arguments: value.arguments.clone(),
217 index: value.index,
218 }
219 }
220}
221
222pub(crate) struct StreamHandlingFailure {
226 pub error: AgentError,
227 pub partial_output: Box<InterruptedStreamOutput>,
230}
231
232pub async fn consume_llm_stream(
233 stream: LLMStream,
234 event_tx: &mpsc::Sender<AgentEvent>,
235 cancel_token: &CancellationToken,
236 session_id: &str,
237) -> Result<StreamHandlingOutput, AgentError> {
238 consume_llm_stream_with_context(
239 stream,
240 event_tx,
241 cancel_token,
242 session_id,
243 &StreamTimeoutContext::default(),
244 )
245 .await
246}
247
248pub async fn consume_llm_stream_with_context(
249 stream: LLMStream,
250 event_tx: &mpsc::Sender<AgentEvent>,
251 cancel_token: &CancellationToken,
252 session_id: &str,
253 timeout_context: &StreamTimeoutContext,
254) -> Result<StreamHandlingOutput, AgentError> {
255 consume::consume_llm_stream_internal(
256 stream,
257 Some(event_tx),
258 cancel_token,
259 session_id,
260 timeout_context,
261 )
262 .await
263}
264
265pub(crate) async fn consume_llm_stream_with_context_and_partial(
266 stream: LLMStream,
267 event_tx: &mpsc::Sender<AgentEvent>,
268 cancel_token: &CancellationToken,
269 session_id: &str,
270 timeout_context: &StreamTimeoutContext,
271) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
272 consume::consume_llm_stream_internal_with_partial(
273 stream,
274 Some(event_tx),
275 cancel_token,
276 session_id,
277 timeout_context,
278 )
279 .await
280}
281
282pub async fn consume_llm_stream_silent(
283 stream: LLMStream,
284 cancel_token: &CancellationToken,
285 session_id: &str,
286) -> Result<StreamHandlingOutput, AgentError> {
287 consume_llm_stream_silent_with_context(
288 stream,
289 cancel_token,
290 session_id,
291 &StreamTimeoutContext::default(),
292 )
293 .await
294}
295
296pub async fn consume_llm_stream_silent_with_context(
297 stream: LLMStream,
298 cancel_token: &CancellationToken,
299 session_id: &str,
300 timeout_context: &StreamTimeoutContext,
301) -> Result<StreamHandlingOutput, AgentError> {
302 consume::consume_llm_stream_internal(stream, None, cancel_token, session_id, timeout_context)
303 .await
304}
305
306pub(crate) async fn consume_llm_stream_silent_with_context_and_partial(
307 stream: LLMStream,
308 cancel_token: &CancellationToken,
309 session_id: &str,
310 timeout_context: &StreamTimeoutContext,
311) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
312 consume::consume_llm_stream_internal_with_partial(
313 stream,
314 None,
315 cancel_token,
316 session_id,
317 timeout_context,
318 )
319 .await
320}
321
322#[cfg(test)]
323mod tests;