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    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
122/// Wait for a provider call to establish its response stream while preserving
123/// cancellation and the existing transport-idle policy.
124///
125/// Provider implementations return [`LLMStream`] only after the initial HTTP
126/// response has been established. Without this outer watchdog, a proxy that
127/// accepts the request but never returns response headers can hold the agent
128/// forever before the normal per-frame stream watchdog starts.
129pub(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                // An inner bounded request policy owns this attempt budget.
172                // A shorter outer watchdog must not reset it through turn retry.
173                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/// Raw authoritative usage fields preserved from provider terminal events.
206///
207/// Each field remains optional so callers can distinguish an explicit
208/// provider-reported zero from an omitted value. Flat counters on
209/// [`StreamHandlingOutput`] remain the normalized compatibility view.
210#[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    /// OpenAI Responses cache-write volume. This is raw provider metadata and
219    /// is not folded into the disjoint legacy prompt-cache counters.
220    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
240/// Publish an already consumed response through the existing visible-message
241/// protocol. Final-answer checks use this after deciding which text to keep.
242pub(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    /// Stable identity shared by the safe realtime text and persisted message.
273    pub visible_message: Option<VisibleMessageIdentity>,
274    pub content: String,
275    pub reasoning_content: String,
276    /// Provider-minted signature covering `reasoning_content`, present only
277    /// when the turn's thinking arrived as exactly one signed Anthropic
278    /// `thinking` block — see [`bamboo_llm::LLMChunk::ReasoningSignature`] (#520).
279    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    /// Merged authoritative provider snapshot, when at least one provider-usage
287    /// chunk was observed. Repeated cumulative snapshots are idempotent, absent
288    /// fields do not erase known values, and explicit zeros remain `Some(0)`.
289    pub provider_usage: Option<ProviderUsageSnapshot>,
290    /// Normalized non-cached ("fresh") input, disjoint from the adjacent cache
291    /// counters. When a provider total is available this is derived from that
292    /// total with a saturating, cache-subset policy; the raw total remains in
293    /// [`Self::provider_usage`].
294    pub input_tokens: u64,
295    /// Validated native items awaiting atomic commit with this round's ordinary
296    /// assistant message. Never exposed as streaming UI events.
297    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
309/// Crate-private interrupted-stream payload.  Keeping this separate from the
310/// public successful [`StreamHandlingOutput`] avoids a source-breaking public
311/// field while retaining fragments that finalization intentionally drops or
312/// normalizes.
313pub(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
335/// A stream failure together with every semantic fragment accumulated before
336/// the failure.  The agent round uses this to create a durable, explicitly
337/// interrupted assistant record instead of losing already-visible output.
338pub(crate) struct StreamHandlingFailure {
339    pub error: AgentError,
340    /// Failures are already the cold path; boxing the three-buffer snapshot
341    /// keeps this error small without changing the preserved fragment data.
342    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;