Skip to main content

bamboo_engine/runtime/stream/
handler.rs

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/// Resolved identifiers and deadlines for one stream. Identifiers are sanitized
22/// before they reach timeout diagnostics; prompts and provider payloads are
23/// never retained here.
24#[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    /// Mark this as the primary turn response. Only that stream can safely ask
54    /// the outer agent loop to replay a timeout that occurs before semantic
55    /// output; auxiliary streams may run after durable turn side effects.
56    pub(crate) fn allow_turn_retry_before_semantic_output(mut self) -> Self {
57        self.turn_retry_eligible = true;
58        self
59    }
60
61    /// Bind a fresh request-dispatch timestamp so the first-semantic deadline
62    /// includes provider bootstrap time as documented.
63    pub(crate) fn begin_request(mut self) -> Self {
64        self.request_started_at = Some(Instant::now());
65        self
66    }
67
68    /// Managed establishment has its own headers deadline/retry policy. Start
69    /// the independent stream watchdog when the successful response arrives.
70    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
129/// Wait for a provider call to establish its response stream while preserving
130/// cancellation and the existing transport-idle policy.
131///
132/// Provider implementations return [`LLMStream`] only after the initial HTTP
133/// response has been established. Without this outer watchdog, a proxy that
134/// accepts the request but never returns response headers can hold the agent
135/// forever before the normal per-frame stream watchdog starts.
136pub(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                // A built-in request policy bounds each headers attempt itself.
178                // Preserve cancellation, but do not cut short its configured
179                // total budget or explicit continuous backoff. Providers that
180                // never enter the managed send retain this outer watchdog.
181                while http_retry_observer.request_policy_active() {
182                    // Poll only this ephemeral phase marker. After managed
183                    // send/backoff returns, a hung provider parser must still
184                    // expire rather than inherit an unbounded bootstrap wait.
185                    tokio::time::sleep(Duration::from_secs(1)).await;
186                }
187            } => {
188                let now = Instant::now();
189                let mut context = timeout_context.clone();
190                // An inner bounded request policy owns this attempt budget.
191                // A shorter outer watchdog must not reset it through turn retry.
192                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/// Raw authoritative usage fields preserved from provider terminal events.
225///
226/// Each field remains optional so callers can distinguish an explicit
227/// provider-reported zero from an omitted value. Flat counters on
228/// [`StreamHandlingOutput`] remain the normalized compatibility view.
229#[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    /// OpenAI Responses cache-write volume. This is raw provider metadata and
238    /// is not folded into the disjoint legacy prompt-cache counters.
239    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
259/// Publish an already consumed response through the existing visible-message
260/// protocol. Final-answer checks use this after deciding which text to keep.
261pub(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    /// Stable identity shared by the safe realtime text and persisted message.
292    pub visible_message: Option<VisibleMessageIdentity>,
293    pub content: String,
294    pub reasoning_content: String,
295    /// Provider-minted signature covering `reasoning_content`, present only
296    /// when the turn's thinking arrived as exactly one signed Anthropic
297    /// `thinking` block — see [`bamboo_llm::LLMChunk::ReasoningSignature`] (#520).
298    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    /// Merged authoritative provider snapshot, when at least one provider-usage
306    /// chunk was observed. Repeated cumulative snapshots are idempotent, absent
307    /// fields do not erase known values, and explicit zeros remain `Some(0)`.
308    pub provider_usage: Option<ProviderUsageSnapshot>,
309    /// Normalized non-cached ("fresh") input, disjoint from the adjacent cache
310    /// counters. When a provider total is available this is derived from that
311    /// total with a saturating, cache-subset policy; the raw total remains in
312    /// [`Self::provider_usage`].
313    pub input_tokens: u64,
314    /// Validated native items awaiting atomic commit with this round's ordinary
315    /// assistant message. Never exposed as streaming UI events.
316    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
328/// Crate-private interrupted-stream payload.  Keeping this separate from the
329/// public successful [`StreamHandlingOutput`] avoids a source-breaking public
330/// field while retaining fragments that finalization intentionally drops or
331/// normalizes.
332pub(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
354/// A stream failure together with every semantic fragment accumulated before
355/// the failure.  The agent round uses this to create a durable, explicitly
356/// interrupted assistant record instead of losing already-visible output.
357pub(crate) struct StreamHandlingFailure {
358    pub error: AgentError,
359    /// Failures are already the cold path; boxing the three-buffer snapshot
360    /// keeps this error small without changing the preserved fragment data.
361    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;