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 disable_turn_retry(&mut self) {
69 self.turn_retry_eligible = false;
70 }
71
72 fn timeout_error(
73 &self,
74 session_id: &str,
75 phase: StreamTimeoutPhase,
76 deadline: Duration,
77 last_transport: Duration,
78 last_semantic: Option<Duration>,
79 ) -> AgentError {
80 self.timeout_error_with_http_retry(
81 session_id,
82 phase,
83 deadline,
84 last_transport,
85 last_semantic,
86 None,
87 )
88 }
89
90 fn timeout_error_with_http_retry(
91 &self,
92 session_id: &str,
93 phase: StreamTimeoutPhase,
94 deadline: Duration,
95 last_transport: Duration,
96 last_semantic: Option<Duration>,
97 http_retry: Option<InitialHttpRetry>,
98 ) -> AgentError {
99 let mut timeout = StreamTimeoutError::new(
100 phase,
101 deadline,
102 self.provider.clone(),
103 self.model.clone(),
104 last_transport,
105 last_semantic,
106 self.turn_retry_eligible,
107 );
108 if let Some(retry) = http_retry {
109 timeout = timeout.with_last_http_retry(retry.status, retry.delay);
110 }
111 tracing::warn!("[{}] LLM stream watchdog expired: {}", session_id, timeout,);
112 AgentError::StreamTimeout(timeout)
113 }
114}
115
116impl Default for StreamTimeoutContext {
117 fn default() -> Self {
118 Self::new(StreamTimeoutConfig::default(), None, None)
119 }
120}
121
122pub(crate) async fn await_stream_bootstrap<F, T>(
130 future: F,
131 cancel_token: &CancellationToken,
132 session_id: &str,
133 timeout_context: &StreamTimeoutContext,
134) -> Result<T, AgentError>
135where
136 F: Future<Output = T>,
137{
138 await_stream_bootstrap_observed(
139 future,
140 cancel_token,
141 session_id,
142 timeout_context,
143 Arc::new(InitialHttpRetryObserver::default()),
144 )
145 .await
146}
147
148pub(crate) async fn await_stream_bootstrap_observed<F, T>(
149 future: F,
150 cancel_token: &CancellationToken,
151 session_id: &str,
152 timeout_context: &StreamTimeoutContext,
153 http_retry_observer: Arc<InitialHttpRetryObserver>,
154) -> Result<T, AgentError>
155where
156 F: Future<Output = T>,
157{
158 let started_at = timeout_context
159 .request_started_at
160 .unwrap_or_else(Instant::now);
161 let deadline = Duration::from_secs(timeout_context.policy.transport_idle_timeout_secs);
162 let expires_at = started_at + deadline;
163 bamboo_llm::retry::observe_initial_http_retries(http_retry_observer.clone(), async {
164 tokio::select! {
165 biased;
166 _ = cancel_token.cancelled() => Err(AgentError::Cancelled),
167 result = future => Ok(result),
168 _ = tokio::time::sleep_until(expires_at) => {
169 let now = Instant::now();
170 let mut context = timeout_context.clone();
171 context.turn_retry_eligible &= !http_retry_observer.request_budget_managed();
174 Err(context.timeout_error_with_http_retry(
175 session_id,
176 StreamTimeoutPhase::Bootstrap,
177 deadline,
178 now.saturating_duration_since(started_at),
179 None,
180 http_retry_observer.last(),
181 ))
182 }
183 }
184 })
185 .await
186}
187
188fn sanitize_identifier(value: &str) -> Option<String> {
189 let value = value.trim();
190 if value.is_empty() {
191 return None;
192 }
193 let sanitized: String = value
194 .chars()
195 .take(120)
196 .map(|character| match character {
197 character if character.is_ascii_alphanumeric() => character,
198 '-' | '_' | '.' | ':' | '/' | '@' => character,
199 _ => '_',
200 })
201 .collect();
202 (!sanitized.is_empty()).then_some(sanitized)
203}
204
205#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
211pub struct ProviderUsageSnapshot {
212 pub input_tokens: Option<u64>,
213 pub output_tokens: Option<u64>,
214 pub total_tokens: Option<u64>,
215 pub reasoning_tokens: Option<u64>,
216 pub cache_creation_input_tokens: Option<u64>,
217 pub cache_read_input_tokens: Option<u64>,
218 pub cache_write_input_tokens: Option<u64>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct VisibleMessageIdentity {
225 pub message_id: String,
226 pub created_at: DateTime<Utc>,
227}
228
229impl VisibleMessageIdentity {
230 pub(crate) fn apply_to(
231 self,
232 mut message: bamboo_agent_core::Message,
233 ) -> bamboo_agent_core::Message {
234 message.id = self.message_id;
235 message.created_at = self.created_at;
236 message
237 }
238}
239
240pub(crate) async fn publish_buffered_response(
243 event_tx: &mpsc::Sender<AgentEvent>,
244 identity: &VisibleMessageIdentity,
245 content: &str,
246 reasoning: Option<&str>,
247) {
248 let _ = event_tx
249 .send(AgentEvent::VisibleMessageStart {
250 message_id: identity.message_id.clone(),
251 created_at: identity.created_at,
252 })
253 .await;
254 if let Some(reasoning) = reasoning.filter(|text| !text.is_empty()) {
255 let _ = event_tx
256 .send(AgentEvent::ReasoningToken {
257 content: reasoning.to_string(),
258 })
259 .await;
260 }
261 if !content.is_empty() {
262 let _ = event_tx
263 .send(AgentEvent::Token {
264 content: content.to_string(),
265 })
266 .await;
267 }
268}
269
270pub struct StreamHandlingOutput {
271 pub response_id: Option<String>,
272 pub visible_message: Option<VisibleMessageIdentity>,
274 pub content: String,
275 pub reasoning_content: String,
276 pub reasoning_signature: Option<String>,
280 pub token_count: usize,
281 pub tool_calls: Vec<ToolCall>,
282 pub output_tokens: u64,
283 pub thinking_tokens: u64,
284 pub cache_creation_input_tokens: u64,
285 pub cache_read_input_tokens: u64,
286 pub provider_usage: Option<ProviderUsageSnapshot>,
290 pub input_tokens: u64,
295 pub provider_transcript_items: Vec<bamboo_domain::ProviderTranscriptItem>,
298}
299
300#[derive(Debug, Clone, serde::Serialize)]
301pub(crate) struct PartialToolCallSnapshot {
302 pub id: String,
303 pub tool_type: String,
304 pub name: String,
305 pub arguments: String,
306 pub index: Option<u32>,
307}
308
309pub(crate) struct InterruptedStreamOutput {
314 pub visible_message: Option<VisibleMessageIdentity>,
315 pub content: String,
316 pub reasoning_content: String,
317 pub partial_tool_calls: Vec<PartialToolCallSnapshot>,
318 pub provider_usage: Option<ProviderUsageSnapshot>,
319 pub input_tokens: u64,
320 pub output_tokens: u64,
321}
322
323impl From<&bamboo_agent_core::tools::PartialToolCall> for PartialToolCallSnapshot {
324 fn from(value: &bamboo_agent_core::tools::PartialToolCall) -> Self {
325 Self {
326 id: value.id.clone(),
327 tool_type: value.tool_type.clone(),
328 name: value.name.clone(),
329 arguments: value.arguments.clone(),
330 index: value.index,
331 }
332 }
333}
334
335pub(crate) struct StreamHandlingFailure {
339 pub error: AgentError,
340 pub partial_output: Box<InterruptedStreamOutput>,
343}
344
345pub async fn consume_llm_stream(
346 stream: LLMStream,
347 event_tx: &mpsc::Sender<AgentEvent>,
348 cancel_token: &CancellationToken,
349 session_id: &str,
350) -> Result<StreamHandlingOutput, AgentError> {
351 consume_llm_stream_with_context(
352 stream,
353 event_tx,
354 cancel_token,
355 session_id,
356 &StreamTimeoutContext::default(),
357 )
358 .await
359}
360
361pub async fn consume_llm_stream_with_context(
362 stream: LLMStream,
363 event_tx: &mpsc::Sender<AgentEvent>,
364 cancel_token: &CancellationToken,
365 session_id: &str,
366 timeout_context: &StreamTimeoutContext,
367) -> Result<StreamHandlingOutput, AgentError> {
368 consume::consume_llm_stream_internal(
369 stream,
370 Some(event_tx),
371 cancel_token,
372 session_id,
373 timeout_context,
374 )
375 .await
376}
377
378pub(crate) async fn consume_llm_stream_with_context_and_partial(
379 stream: LLMStream,
380 event_tx: &mpsc::Sender<AgentEvent>,
381 cancel_token: &CancellationToken,
382 session_id: &str,
383 timeout_context: &StreamTimeoutContext,
384) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
385 consume::consume_llm_stream_internal_with_partial(
386 stream,
387 Some(event_tx),
388 cancel_token,
389 session_id,
390 timeout_context,
391 )
392 .await
393}
394
395pub async fn consume_llm_stream_silent(
396 stream: LLMStream,
397 cancel_token: &CancellationToken,
398 session_id: &str,
399) -> Result<StreamHandlingOutput, AgentError> {
400 consume_llm_stream_silent_with_context(
401 stream,
402 cancel_token,
403 session_id,
404 &StreamTimeoutContext::default(),
405 )
406 .await
407}
408
409pub async fn consume_llm_stream_silent_with_context(
410 stream: LLMStream,
411 cancel_token: &CancellationToken,
412 session_id: &str,
413 timeout_context: &StreamTimeoutContext,
414) -> Result<StreamHandlingOutput, AgentError> {
415 consume::consume_llm_stream_internal(stream, None, cancel_token, session_id, timeout_context)
416 .await
417}
418
419pub(crate) async fn consume_llm_stream_silent_with_context_and_partial(
420 stream: LLMStream,
421 cancel_token: &CancellationToken,
422 session_id: &str,
423 timeout_context: &StreamTimeoutContext,
424) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
425 consume::consume_llm_stream_internal_with_partial(
426 stream,
427 None,
428 cancel_token,
429 session_id,
430 timeout_context,
431 )
432 .await
433}
434
435#[cfg(test)]
436mod tests;