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 pub provider_transcript_items: Vec<bamboo_domain::ProviderTranscriptItem>,
192}
193
194#[derive(Debug, Clone, serde::Serialize)]
195pub(crate) struct PartialToolCallSnapshot {
196 pub id: String,
197 pub tool_type: String,
198 pub name: String,
199 pub arguments: String,
200 pub index: Option<u32>,
201}
202
203pub(crate) struct InterruptedStreamOutput {
208 pub content: String,
209 pub reasoning_content: String,
210 pub partial_tool_calls: Vec<PartialToolCallSnapshot>,
211}
212
213impl From<&bamboo_agent_core::tools::PartialToolCall> for PartialToolCallSnapshot {
214 fn from(value: &bamboo_agent_core::tools::PartialToolCall) -> Self {
215 Self {
216 id: value.id.clone(),
217 tool_type: value.tool_type.clone(),
218 name: value.name.clone(),
219 arguments: value.arguments.clone(),
220 index: value.index,
221 }
222 }
223}
224
225pub(crate) struct StreamHandlingFailure {
229 pub error: AgentError,
230 pub partial_output: Box<InterruptedStreamOutput>,
233}
234
235pub async fn consume_llm_stream(
236 stream: LLMStream,
237 event_tx: &mpsc::Sender<AgentEvent>,
238 cancel_token: &CancellationToken,
239 session_id: &str,
240) -> Result<StreamHandlingOutput, AgentError> {
241 consume_llm_stream_with_context(
242 stream,
243 event_tx,
244 cancel_token,
245 session_id,
246 &StreamTimeoutContext::default(),
247 )
248 .await
249}
250
251pub async fn consume_llm_stream_with_context(
252 stream: LLMStream,
253 event_tx: &mpsc::Sender<AgentEvent>,
254 cancel_token: &CancellationToken,
255 session_id: &str,
256 timeout_context: &StreamTimeoutContext,
257) -> Result<StreamHandlingOutput, AgentError> {
258 consume::consume_llm_stream_internal(
259 stream,
260 Some(event_tx),
261 cancel_token,
262 session_id,
263 timeout_context,
264 )
265 .await
266}
267
268pub(crate) async fn consume_llm_stream_with_context_and_partial(
269 stream: LLMStream,
270 event_tx: &mpsc::Sender<AgentEvent>,
271 cancel_token: &CancellationToken,
272 session_id: &str,
273 timeout_context: &StreamTimeoutContext,
274) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
275 consume::consume_llm_stream_internal_with_partial(
276 stream,
277 Some(event_tx),
278 cancel_token,
279 session_id,
280 timeout_context,
281 )
282 .await
283}
284
285pub async fn consume_llm_stream_silent(
286 stream: LLMStream,
287 cancel_token: &CancellationToken,
288 session_id: &str,
289) -> Result<StreamHandlingOutput, AgentError> {
290 consume_llm_stream_silent_with_context(
291 stream,
292 cancel_token,
293 session_id,
294 &StreamTimeoutContext::default(),
295 )
296 .await
297}
298
299pub async fn consume_llm_stream_silent_with_context(
300 stream: LLMStream,
301 cancel_token: &CancellationToken,
302 session_id: &str,
303 timeout_context: &StreamTimeoutContext,
304) -> Result<StreamHandlingOutput, AgentError> {
305 consume::consume_llm_stream_internal(stream, None, cancel_token, session_id, timeout_context)
306 .await
307}
308
309pub(crate) async fn consume_llm_stream_silent_with_context_and_partial(
310 stream: LLMStream,
311 cancel_token: &CancellationToken,
312 session_id: &str,
313 timeout_context: &StreamTimeoutContext,
314) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
315 consume::consume_llm_stream_internal_with_partial(
316 stream,
317 None,
318 cancel_token,
319 session_id,
320 timeout_context,
321 )
322 .await
323}
324
325#[cfg(test)]
326mod tests;