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
65pub struct StreamHandlingOutput {
66 pub response_id: Option<String>,
67 pub content: String,
68 pub reasoning_content: String,
69 pub reasoning_signature: Option<String>,
73 pub token_count: usize,
74 pub tool_calls: Vec<ToolCall>,
75 pub output_tokens: u64,
76 pub thinking_tokens: u64,
77 pub cache_creation_input_tokens: u64,
78 pub cache_read_input_tokens: u64,
79 pub input_tokens: u64,
80}
81
82#[derive(Debug, Clone, serde::Serialize)]
83pub(crate) struct PartialToolCallSnapshot {
84 pub id: String,
85 pub tool_type: String,
86 pub name: String,
87 pub arguments: String,
88 pub index: Option<u32>,
89}
90
91pub(crate) struct InterruptedStreamOutput {
96 pub content: String,
97 pub reasoning_content: String,
98 pub partial_tool_calls: Vec<PartialToolCallSnapshot>,
99}
100
101impl From<&bamboo_agent_core::tools::PartialToolCall> for PartialToolCallSnapshot {
102 fn from(value: &bamboo_agent_core::tools::PartialToolCall) -> Self {
103 Self {
104 id: value.id.clone(),
105 tool_type: value.tool_type.clone(),
106 name: value.name.clone(),
107 arguments: value.arguments.clone(),
108 index: value.index,
109 }
110 }
111}
112
113pub(crate) struct StreamHandlingFailure {
117 pub error: AgentError,
118 pub partial_output: InterruptedStreamOutput,
119}
120
121pub async fn consume_llm_stream(
122 stream: LLMStream,
123 event_tx: &mpsc::Sender<AgentEvent>,
124 cancel_token: &CancellationToken,
125 session_id: &str,
126) -> Result<StreamHandlingOutput, AgentError> {
127 consume_llm_stream_with_context(
128 stream,
129 event_tx,
130 cancel_token,
131 session_id,
132 &StreamTimeoutContext::default(),
133 )
134 .await
135}
136
137pub async fn consume_llm_stream_with_context(
138 stream: LLMStream,
139 event_tx: &mpsc::Sender<AgentEvent>,
140 cancel_token: &CancellationToken,
141 session_id: &str,
142 timeout_context: &StreamTimeoutContext,
143) -> Result<StreamHandlingOutput, AgentError> {
144 consume::consume_llm_stream_internal(
145 stream,
146 Some(event_tx),
147 cancel_token,
148 session_id,
149 timeout_context,
150 )
151 .await
152}
153
154pub(crate) async fn consume_llm_stream_with_context_and_partial(
155 stream: LLMStream,
156 event_tx: &mpsc::Sender<AgentEvent>,
157 cancel_token: &CancellationToken,
158 session_id: &str,
159 timeout_context: &StreamTimeoutContext,
160) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
161 consume::consume_llm_stream_internal_with_partial(
162 stream,
163 Some(event_tx),
164 cancel_token,
165 session_id,
166 timeout_context,
167 )
168 .await
169}
170
171pub async fn consume_llm_stream_silent(
172 stream: LLMStream,
173 cancel_token: &CancellationToken,
174 session_id: &str,
175) -> Result<StreamHandlingOutput, AgentError> {
176 consume_llm_stream_silent_with_context(
177 stream,
178 cancel_token,
179 session_id,
180 &StreamTimeoutContext::default(),
181 )
182 .await
183}
184
185pub async fn consume_llm_stream_silent_with_context(
186 stream: LLMStream,
187 cancel_token: &CancellationToken,
188 session_id: &str,
189 timeout_context: &StreamTimeoutContext,
190) -> Result<StreamHandlingOutput, AgentError> {
191 consume::consume_llm_stream_internal(stream, None, cancel_token, session_id, timeout_context)
192 .await
193}
194
195pub(crate) async fn consume_llm_stream_silent_with_context_and_partial(
196 stream: LLMStream,
197 cancel_token: &CancellationToken,
198 session_id: &str,
199 timeout_context: &StreamTimeoutContext,
200) -> Result<StreamHandlingOutput, StreamHandlingFailure> {
201 consume::consume_llm_stream_internal_with_partial(
202 stream,
203 None,
204 cancel_token,
205 session_id,
206 timeout_context,
207 )
208 .await
209}
210
211#[cfg(test)]
212mod tests;