bamboo_engine/runtime/stream/
handler.rs1use tokio::sync::mpsc;
2use tokio_util::sync::CancellationToken;
3
4use bamboo_agent_core::tools::ToolCall;
5use bamboo_agent_core::{AgentError, AgentEvent};
6use bamboo_config::StreamTimeoutConfig;
7use bamboo_llm::LLMStream;
8
9mod chunk_handling;
10mod consume;
11mod stream_state;
12
13#[derive(Debug, Clone)]
17pub struct StreamTimeoutContext {
18 pub(crate) policy: StreamTimeoutConfig,
19 pub(crate) provider: Option<String>,
20 pub(crate) model: Option<String>,
21}
22
23impl StreamTimeoutContext {
24 pub fn new(policy: StreamTimeoutConfig, provider: Option<&str>, model: Option<&str>) -> Self {
25 let policy = match policy.validate() {
26 Ok(()) => policy,
27 Err(error) => {
28 tracing::warn!(
29 "invalid programmatic stream timeout policy ({error}); using safe defaults"
30 );
31 StreamTimeoutConfig::default()
32 }
33 };
34 Self {
35 policy,
36 provider: provider.and_then(sanitize_identifier),
37 model: model.and_then(sanitize_identifier),
38 }
39 }
40}
41
42impl Default for StreamTimeoutContext {
43 fn default() -> Self {
44 Self::new(StreamTimeoutConfig::default(), None, None)
45 }
46}
47
48fn sanitize_identifier(value: &str) -> Option<String> {
49 let value = value.trim();
50 if value.is_empty() {
51 return None;
52 }
53 let sanitized: String = value
54 .chars()
55 .take(120)
56 .map(|character| match character {
57 character if character.is_ascii_alphanumeric() => character,
58 '-' | '_' | '.' | ':' | '/' | '@' => character,
59 _ => '_',
60 })
61 .collect();
62 (!sanitized.is_empty()).then_some(sanitized)
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub struct ProviderUsageSnapshot {
72 pub input_tokens: Option<u64>,
73 pub output_tokens: Option<u64>,
74 pub total_tokens: Option<u64>,
75 pub reasoning_tokens: Option<u64>,
76 pub cache_creation_input_tokens: Option<u64>,
77 pub cache_read_input_tokens: Option<u64>,
78 pub cache_write_input_tokens: Option<u64>,
81}
82
83pub struct StreamHandlingOutput {
84 pub response_id: Option<String>,
85 pub content: String,
86 pub reasoning_content: String,
87 pub reasoning_signature: Option<String>,
91 pub token_count: usize,
92 pub tool_calls: Vec<ToolCall>,
93 pub output_tokens: u64,
94 pub thinking_tokens: u64,
95 pub cache_creation_input_tokens: u64,
96 pub cache_read_input_tokens: u64,
97 pub provider_usage: Option<ProviderUsageSnapshot>,
101 pub input_tokens: u64,
106}
107
108impl StreamHandlingOutput {
109 pub(crate) fn prompt_tokens_for_runtime_budget(&self) -> u64 {
114 self.provider_usage
115 .and_then(|usage| usage.input_tokens)
116 .unwrap_or(self.input_tokens)
117 }
118
119 pub(crate) fn completion_tokens_for_runtime_budget(&self) -> u64 {
124 self.provider_usage
125 .and_then(|usage| usage.output_tokens)
126 .unwrap_or(self.output_tokens)
127 }
128}
129
130#[derive(Debug, Clone, serde::Serialize)]
131pub(crate) struct PartialToolCallSnapshot {
132 pub id: String,
133 pub tool_type: String,
134 pub name: String,
135 pub arguments: String,
136 pub index: Option<u32>,
137}
138
139pub(crate) struct InterruptedStreamOutput {
144 pub content: String,
145 pub reasoning_content: String,
146 pub partial_tool_calls: Vec<PartialToolCallSnapshot>,
147}
148
149impl From<&bamboo_agent_core::tools::PartialToolCall> for PartialToolCallSnapshot {
150 fn from(value: &bamboo_agent_core::tools::PartialToolCall) -> Self {
151 Self {
152 id: value.id.clone(),
153 tool_type: value.tool_type.clone(),
154 name: value.name.clone(),
155 arguments: value.arguments.clone(),
156 index: value.index,
157 }
158 }
159}
160
161pub(crate) struct StreamHandlingFailure {
165 pub error: AgentError,
166 pub partial_output: InterruptedStreamOutput,
167}
168
169pub async fn consume_llm_stream(
170 stream: LLMStream,
171 event_tx: &mpsc::Sender<AgentEvent>,
172 cancel_token: &CancellationToken,
173 session_id: &str,
174) -> Result<StreamHandlingOutput, AgentError> {
175 consume_llm_stream_with_context(
176 stream,
177 event_tx,
178 cancel_token,
179 session_id,
180 &StreamTimeoutContext::default(),
181 )
182 .await
183}
184
185pub async fn consume_llm_stream_with_context(
186 stream: LLMStream,
187 event_tx: &mpsc::Sender<AgentEvent>,
188 cancel_token: &CancellationToken,
189 session_id: &str,
190 timeout_context: &StreamTimeoutContext,
191) -> Result<StreamHandlingOutput, AgentError> {
192 consume::consume_llm_stream_internal(
193 stream,
194 Some(event_tx),
195 cancel_token,
196 session_id,
197 timeout_context,
198 )
199 .await
200}
201
202pub(crate) async fn consume_llm_stream_with_context_and_partial(
203 stream: LLMStream,
204 event_tx: &mpsc::Sender<AgentEvent>,
205 cancel_token: &CancellationToken,
206 session_id: &str,
207 timeout_context: &StreamTimeoutContext,
208) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
209 consume::consume_llm_stream_internal_with_partial(
210 stream,
211 Some(event_tx),
212 cancel_token,
213 session_id,
214 timeout_context,
215 )
216 .await
217}
218
219pub async fn consume_llm_stream_silent(
220 stream: LLMStream,
221 cancel_token: &CancellationToken,
222 session_id: &str,
223) -> Result<StreamHandlingOutput, AgentError> {
224 consume_llm_stream_silent_with_context(
225 stream,
226 cancel_token,
227 session_id,
228 &StreamTimeoutContext::default(),
229 )
230 .await
231}
232
233pub async fn consume_llm_stream_silent_with_context(
234 stream: LLMStream,
235 cancel_token: &CancellationToken,
236 session_id: &str,
237 timeout_context: &StreamTimeoutContext,
238) -> Result<StreamHandlingOutput, AgentError> {
239 consume::consume_llm_stream_internal(stream, None, cancel_token, session_id, timeout_context)
240 .await
241}
242
243pub(crate) async fn consume_llm_stream_silent_with_context_and_partial(
244 stream: LLMStream,
245 cancel_token: &CancellationToken,
246 session_id: &str,
247 timeout_context: &StreamTimeoutContext,
248) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
249 consume::consume_llm_stream_internal_with_partial(
250 stream,
251 None,
252 cancel_token,
253 session_id,
254 timeout_context,
255 )
256 .await
257}
258
259#[cfg(test)]
260mod tests;