1use std::future::Future;
2use std::sync::Arc;
3use std::time::Duration;
4
5use chrono::{DateTime, Utc};
6
7use tokio::sync::mpsc;
8use tokio::time::Instant;
9use tokio_util::sync::CancellationToken;
10
11use bamboo_agent_core::tools::ToolCall;
12use bamboo_agent_core::{AgentError, AgentEvent, StreamTimeoutError, StreamTimeoutPhase};
13use bamboo_config::StreamTimeoutConfig;
14use bamboo_llm::retry::{InitialHttpRetry, InitialHttpRetryObserver};
15use bamboo_llm::LLMStream;
16
17mod chunk_handling;
18mod consume;
19mod stream_state;
20
21#[derive(Debug, Clone)]
25pub struct StreamTimeoutContext {
26 pub(crate) policy: StreamTimeoutConfig,
27 pub(crate) provider: Option<String>,
28 pub(crate) model: Option<String>,
29 request_started_at: Option<Instant>,
30 turn_retry_eligible: bool,
31}
32
33impl StreamTimeoutContext {
34 pub fn new(policy: StreamTimeoutConfig, provider: Option<&str>, model: Option<&str>) -> Self {
35 let policy = match policy.validate() {
36 Ok(()) => policy,
37 Err(error) => {
38 tracing::warn!(
39 "invalid programmatic stream timeout policy ({error}); using safe defaults"
40 );
41 StreamTimeoutConfig::default()
42 }
43 };
44 Self {
45 policy,
46 provider: provider.and_then(sanitize_identifier),
47 model: model.and_then(sanitize_identifier),
48 request_started_at: None,
49 turn_retry_eligible: false,
50 }
51 }
52
53 pub(crate) fn allow_turn_retry_before_semantic_output(mut self) -> Self {
57 self.turn_retry_eligible = true;
58 self
59 }
60
61 pub(crate) fn begin_request(mut self) -> Self {
64 self.request_started_at = Some(Instant::now());
65 self
66 }
67
68 pub(crate) fn managed_response_established(&mut self) {
71 self.request_started_at = Some(Instant::now());
72 self.disable_turn_retry();
73 }
74
75 pub(crate) fn disable_turn_retry(&mut self) {
76 self.turn_retry_eligible = false;
77 }
78
79 fn timeout_error(
80 &self,
81 session_id: &str,
82 phase: StreamTimeoutPhase,
83 deadline: Duration,
84 last_transport: Duration,
85 last_semantic: Option<Duration>,
86 ) -> AgentError {
87 self.timeout_error_with_http_retry(
88 session_id,
89 phase,
90 deadline,
91 last_transport,
92 last_semantic,
93 None,
94 )
95 }
96
97 fn timeout_error_with_http_retry(
98 &self,
99 session_id: &str,
100 phase: StreamTimeoutPhase,
101 deadline: Duration,
102 last_transport: Duration,
103 last_semantic: Option<Duration>,
104 http_retry: Option<InitialHttpRetry>,
105 ) -> AgentError {
106 let mut timeout = StreamTimeoutError::new(
107 phase,
108 deadline,
109 self.provider.clone(),
110 self.model.clone(),
111 last_transport,
112 last_semantic,
113 self.turn_retry_eligible,
114 );
115 if let Some(retry) = http_retry {
116 timeout = timeout.with_last_http_retry(retry.status, retry.delay);
117 }
118 tracing::warn!("[{}] LLM stream watchdog expired: {}", session_id, timeout,);
119 AgentError::StreamTimeout(timeout)
120 }
121}
122
123impl Default for StreamTimeoutContext {
124 fn default() -> Self {
125 Self::new(StreamTimeoutConfig::default(), None, None)
126 }
127}
128
129pub(crate) async fn await_stream_bootstrap<F, T>(
137 future: F,
138 cancel_token: &CancellationToken,
139 session_id: &str,
140 timeout_context: &StreamTimeoutContext,
141) -> Result<T, AgentError>
142where
143 F: Future<Output = T>,
144{
145 await_stream_bootstrap_observed(
146 future,
147 cancel_token,
148 session_id,
149 timeout_context,
150 Arc::new(InitialHttpRetryObserver::default()),
151 )
152 .await
153}
154
155pub(crate) async fn await_stream_bootstrap_observed<F, T>(
156 future: F,
157 cancel_token: &CancellationToken,
158 session_id: &str,
159 timeout_context: &StreamTimeoutContext,
160 http_retry_observer: Arc<InitialHttpRetryObserver>,
161) -> Result<T, AgentError>
162where
163 F: Future<Output = T>,
164{
165 let started_at = timeout_context
166 .request_started_at
167 .unwrap_or_else(Instant::now);
168 let deadline = Duration::from_secs(timeout_context.policy.transport_idle_timeout_secs);
169 let expires_at = started_at + deadline;
170 bamboo_llm::retry::observe_initial_http_retries(http_retry_observer.clone(), async {
171 tokio::select! {
172 biased;
173 _ = cancel_token.cancelled() => Err(AgentError::Cancelled),
174 result = future => Ok(result),
175 _ = async {
176 tokio::time::sleep_until(expires_at).await;
177 while http_retry_observer.request_policy_active() {
182 tokio::time::sleep(Duration::from_secs(1)).await;
186 }
187 } => {
188 let now = Instant::now();
189 let mut context = timeout_context.clone();
190 context.turn_retry_eligible &= !http_retry_observer.request_budget_managed();
193 Err(context.timeout_error_with_http_retry(
194 session_id,
195 StreamTimeoutPhase::Bootstrap,
196 deadline,
197 now.saturating_duration_since(started_at),
198 None,
199 http_retry_observer.last(),
200 ))
201 }
202 }
203 })
204 .await
205}
206
207fn sanitize_identifier(value: &str) -> Option<String> {
208 let value = value.trim();
209 if value.is_empty() {
210 return None;
211 }
212 let sanitized: String = value
213 .chars()
214 .take(120)
215 .map(|character| match character {
216 character if character.is_ascii_alphanumeric() => character,
217 '-' | '_' | '.' | ':' | '/' | '@' => character,
218 _ => '_',
219 })
220 .collect();
221 (!sanitized.is_empty()).then_some(sanitized)
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
230pub struct ProviderUsageSnapshot {
231 pub input_tokens: Option<u64>,
232 pub output_tokens: Option<u64>,
233 pub total_tokens: Option<u64>,
234 pub reasoning_tokens: Option<u64>,
235 pub cache_creation_input_tokens: Option<u64>,
236 pub cache_read_input_tokens: Option<u64>,
237 pub cache_write_input_tokens: Option<u64>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct VisibleMessageIdentity {
244 pub message_id: String,
245 pub created_at: DateTime<Utc>,
246}
247
248impl VisibleMessageIdentity {
249 pub(crate) fn apply_to(
250 self,
251 mut message: bamboo_agent_core::Message,
252 ) -> bamboo_agent_core::Message {
253 message.id = self.message_id;
254 message.created_at = self.created_at;
255 message
256 }
257}
258
259pub(crate) async fn publish_buffered_response(
262 event_tx: &mpsc::Sender<AgentEvent>,
263 identity: &VisibleMessageIdentity,
264 content: &str,
265 reasoning: Option<&str>,
266) {
267 let _ = event_tx
268 .send(AgentEvent::VisibleMessageStart {
269 message_id: identity.message_id.clone(),
270 created_at: identity.created_at,
271 })
272 .await;
273 if let Some(reasoning) = reasoning.filter(|text| !text.is_empty()) {
274 let _ = event_tx
275 .send(AgentEvent::ReasoningToken {
276 content: reasoning.to_string(),
277 })
278 .await;
279 }
280 if !content.is_empty() {
281 let _ = event_tx
282 .send(AgentEvent::Token {
283 content: content.to_string(),
284 })
285 .await;
286 }
287}
288
289pub struct StreamHandlingOutput {
290 pub response_id: Option<String>,
291 pub visible_message: Option<VisibleMessageIdentity>,
293 pub content: String,
294 pub reasoning_content: String,
295 pub reasoning_signature: Option<String>,
299 pub token_count: usize,
300 pub tool_calls: Vec<ToolCall>,
301 pub output_tokens: u64,
302 pub thinking_tokens: u64,
303 pub cache_creation_input_tokens: u64,
304 pub cache_read_input_tokens: u64,
305 pub provider_usage: Option<ProviderUsageSnapshot>,
309 pub input_tokens: u64,
314 pub provider_transcript_items: Vec<bamboo_domain::ProviderTranscriptItem>,
317}
318
319#[derive(Debug, Clone, serde::Serialize)]
320pub(crate) struct PartialToolCallSnapshot {
321 pub id: String,
322 pub tool_type: String,
323 pub name: String,
324 pub arguments: String,
325 pub index: Option<u32>,
326}
327
328pub(crate) struct InterruptedStreamOutput {
333 pub visible_message: Option<VisibleMessageIdentity>,
334 pub content: String,
335 pub reasoning_content: String,
336 pub partial_tool_calls: Vec<PartialToolCallSnapshot>,
337 pub provider_usage: Option<ProviderUsageSnapshot>,
338 pub input_tokens: u64,
339 pub output_tokens: u64,
340}
341
342impl From<&bamboo_agent_core::tools::PartialToolCall> for PartialToolCallSnapshot {
343 fn from(value: &bamboo_agent_core::tools::PartialToolCall) -> Self {
344 Self {
345 id: value.id.clone(),
346 tool_type: value.tool_type.clone(),
347 name: value.name.clone(),
348 arguments: value.arguments.clone(),
349 index: value.index,
350 }
351 }
352}
353
354pub(crate) struct StreamHandlingFailure {
358 pub error: AgentError,
359 pub partial_output: Box<InterruptedStreamOutput>,
362}
363
364pub async fn consume_llm_stream(
365 stream: LLMStream,
366 event_tx: &mpsc::Sender<AgentEvent>,
367 cancel_token: &CancellationToken,
368 session_id: &str,
369) -> Result<StreamHandlingOutput, AgentError> {
370 consume_llm_stream_with_context(
371 stream,
372 event_tx,
373 cancel_token,
374 session_id,
375 &StreamTimeoutContext::default(),
376 )
377 .await
378}
379
380pub async fn consume_llm_stream_with_context(
381 stream: LLMStream,
382 event_tx: &mpsc::Sender<AgentEvent>,
383 cancel_token: &CancellationToken,
384 session_id: &str,
385 timeout_context: &StreamTimeoutContext,
386) -> Result<StreamHandlingOutput, AgentError> {
387 consume::consume_llm_stream_internal(
388 stream,
389 Some(event_tx),
390 cancel_token,
391 session_id,
392 timeout_context,
393 )
394 .await
395}
396
397pub(crate) async fn consume_llm_stream_with_context_and_partial(
398 stream: LLMStream,
399 event_tx: &mpsc::Sender<AgentEvent>,
400 cancel_token: &CancellationToken,
401 session_id: &str,
402 timeout_context: &StreamTimeoutContext,
403) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
404 consume::consume_llm_stream_internal_with_partial(
405 stream,
406 Some(event_tx),
407 cancel_token,
408 session_id,
409 timeout_context,
410 )
411 .await
412}
413
414pub async fn consume_llm_stream_silent(
415 stream: LLMStream,
416 cancel_token: &CancellationToken,
417 session_id: &str,
418) -> Result<StreamHandlingOutput, AgentError> {
419 consume_llm_stream_silent_with_context(
420 stream,
421 cancel_token,
422 session_id,
423 &StreamTimeoutContext::default(),
424 )
425 .await
426}
427
428pub async fn consume_llm_stream_silent_with_context(
429 stream: LLMStream,
430 cancel_token: &CancellationToken,
431 session_id: &str,
432 timeout_context: &StreamTimeoutContext,
433) -> Result<StreamHandlingOutput, AgentError> {
434 consume::consume_llm_stream_internal(stream, None, cancel_token, session_id, timeout_context)
435 .await
436}
437
438pub(crate) async fn consume_llm_stream_silent_with_context_and_partial(
439 stream: LLMStream,
440 cancel_token: &CancellationToken,
441 session_id: &str,
442 timeout_context: &StreamTimeoutContext,
443) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
444 consume::consume_llm_stream_internal_with_partial(
445 stream,
446 None,
447 cancel_token,
448 session_id,
449 timeout_context,
450 )
451 .await
452}
453
454#[cfg(test)]
455mod tests;