bamboo_engine/runtime/managers/adapters/
lifecycle.rs1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::tools::ToolExecutor;
5use bamboo_agent_core::{AgentError, AgentEvent, Session};
6use bamboo_domain::{AgentRuntimeState, AgentStatusState};
7use bamboo_llm::LLMProvider;
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use crate::runtime::config::AgentLoopConfig;
12use crate::runtime::managers::lifecycle::LifecycleManager;
13use crate::runtime::runner::state_bridge;
14use crate::runtime::task_context::TaskLoopContext;
15use bamboo_metrics::MetricsCollector;
16
17pub struct DefaultLifecycleManager {
19 llm: Arc<dyn LLMProvider>,
20}
21
22impl DefaultLifecycleManager {
23 pub fn new(llm: Arc<dyn LLMProvider>) -> Self {
24 Self { llm }
25 }
26}
27
28#[async_trait]
29impl LifecycleManager for DefaultLifecycleManager {
30 fn initialize_run(&self, _session: &Session, config: &AgentLoopConfig) -> AgentRuntimeState {
31 let mut state =
35 AgentRuntimeState::new(crate::runtime::runner::round_prelude::new_execution_id());
36 state.llm.model_name = config.model_name.clone();
37 state.llm.provider_name = config.provider_name.clone();
38 state.llm.fast_model_name = config.fast_model_name.clone();
39 state.llm.background_model_name = config.background_model_name.clone();
40 state.round.max_rounds = config.max_rounds as u32;
41 state.status = AgentStatusState::Initializing;
42 state
43 }
44
45 #[allow(clippy::too_many_arguments)]
46 async fn prepare_round(
47 &self,
48 session: &mut Session,
49 task_context: &mut Option<TaskLoopContext>,
50 runtime_state: &mut AgentRuntimeState,
51 round: usize,
52 max_rounds: usize,
53 config: &AgentLoopConfig,
54 cancel_token: &CancellationToken,
55 metrics_collector: Option<&MetricsCollector>,
56 session_id: &str,
57 model_name: &str,
58 tools: &dyn ToolExecutor,
59 _llm: &dyn LLMProvider,
60 ) -> Result<String, AgentError> {
61 let execution_id = runtime_state.run_id.clone();
62 crate::runtime::runner::round_prelude::prepare_round(
63 session,
64 task_context,
65 runtime_state,
66 config,
67 self.llm.clone(),
68 tools,
69 &crate::runtime::runner::round_prelude::RoundPreludeFrame {
70 execution_id: &execution_id,
71 round,
72 max_rounds,
73 debug_enabled: false, cancel_token,
75 metrics_collector,
76 session_id,
77 model_name,
78 },
79 )
80 .await
81 }
82
83 async fn handle_round_outcome(
84 &self,
85 session: &mut Session,
86 runtime_state: &mut AgentRuntimeState,
87 _task_context: &mut Option<TaskLoopContext>,
88 round: usize,
89 should_break: bool,
90 ) -> Result<bool, AgentError> {
91 runtime_state.round.current_round = round as u32;
92
93 if should_break {
94 runtime_state.status = AgentStatusState::Finalizing;
95 } else if round as u32 >= runtime_state.round.max_rounds {
96 tracing::info!(
97 "[{}] Reached max rounds ({})",
98 session.id,
99 runtime_state.round.max_rounds
100 );
101 return Ok(true);
102 }
103
104 state_bridge::write_runtime_state(session, runtime_state);
105 Ok(should_break)
106 }
107
108 #[allow(clippy::too_many_arguments)]
109 async fn finalize_run(
110 &self,
111 session: &mut Session,
112 runtime_state: &mut AgentRuntimeState,
113 event_tx: &mpsc::Sender<AgentEvent>,
114 session_id: &str,
115 config: &AgentLoopConfig,
116 metrics_collector: Option<&MetricsCollector>,
117 task_context: Option<TaskLoopContext>,
118 ) {
119 runtime_state.status = AgentStatusState::Completed;
120 state_bridge::write_runtime_state(session, runtime_state);
121
122 crate::runtime::runner::session_finalize::finalize_session(
123 task_context,
124 session,
125 event_tx,
126 session_id,
127 config,
128 metrics_collector,
129 false,
130 runtime_state,
131 )
132 .await;
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use bamboo_agent_core::storage::Storage;
140 use bamboo_agent_core::tools::{ToolCall, ToolError, ToolResult, ToolSchema};
141 use bamboo_agent_core::Message;
142 use bamboo_domain::{
143 SessionActivationPolicy, SessionInboxLimits, SessionInboxPort, SessionMessageEnvelope,
144 };
145 use bamboo_llm::provider::LLMStream;
146 use futures::stream;
147
148 struct UnusedProvider;
149
150 #[async_trait]
151 impl LLMProvider for UnusedProvider {
152 async fn chat_stream(
153 &self,
154 _messages: &[Message],
155 _tools: &[bamboo_agent_core::tools::ToolSchema],
156 _max_output_tokens: Option<u32>,
157 _model: &str,
158 ) -> bamboo_llm::provider::Result<LLMStream> {
159 Ok(Box::pin(stream::iter(vec![Ok(bamboo_llm::LLMChunk::Done)])))
160 }
161 }
162
163 struct EmptyTools;
164
165 #[async_trait]
166 impl ToolExecutor for EmptyTools {
167 async fn execute(&self, call: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
168 Err(ToolError::NotFound(call.function.name.clone()))
169 }
170
171 fn list_tools(&self) -> Vec<ToolSchema> {
172 Vec::new()
173 }
174 }
175
176 #[test]
177 fn initialize_run_assigns_a_fresh_execution_identity() {
178 let manager = DefaultLifecycleManager::new(Arc::new(UnusedProvider));
179 let session = Session::new("same-session", "model");
180 let config = AgentLoopConfig::default();
181
182 let first = manager.initialize_run(&session, &config);
183 let second = manager.initialize_run(&session, &config);
184
185 assert!(!first.run_id.is_empty());
186 assert_ne!(first.run_id, session.id);
187 assert_ne!(first.run_id, second.run_id);
188 }
189
190 #[tokio::test]
191 async fn adapter_admits_typed_inbox_input_then_cancels_before_prompt_context() {
192 let directory = tempfile::tempdir().unwrap();
193 let store = Arc::new(
194 bamboo_storage::SessionStoreV2::new(directory.path().to_path_buf())
195 .await
196 .unwrap(),
197 );
198 let storage: Arc<dyn Storage> = store.clone();
199 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> =
200 Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
201 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
202 store,
203 SessionInboxLimits::default(),
204 ));
205 let mut persisted = Session::new("adapter-boundary-before-recall", "model");
206 persisted.add_message(Message::system("base prompt"));
207 storage.save_session(&persisted).await.unwrap();
208
209 let mut running = persisted;
210 let content = "admit this before observing cancellation";
211 let envelope = SessionMessageEnvelope::user_input(&running.id, content);
212 let receipt = inbox.deliver(&envelope).await.unwrap();
213 inbox
214 .mark_activation_eligible(
215 &running.id,
216 receipt.generation,
217 SessionActivationPolicy::InterruptSpecificWait,
218 )
219 .await
220 .unwrap();
221 let config = AgentLoopConfig {
222 storage: Some(storage),
223 persistence: Some(persistence),
224 session_inbox: Some(inbox),
225 app_data_dir: Some(directory.path().to_path_buf()),
226 ..AgentLoopConfig::default()
227 };
228 let provider: Arc<dyn LLMProvider> = Arc::new(UnusedProvider);
229 let manager = DefaultLifecycleManager::new(provider.clone());
230 let mut runtime_state = manager.initialize_run(&running, &config);
231 let mut task_context = None;
232 let cancel = CancellationToken::new();
233 cancel.cancel();
234 let session_id = running.id.clone();
235
236 let error = manager
237 .prepare_round(
238 &mut running,
239 &mut task_context,
240 &mut runtime_state,
241 0,
242 1,
243 &config,
244 &cancel,
245 None,
246 &session_id,
247 "model",
248 &EmptyTools,
249 provider.as_ref(),
250 )
251 .await
252 .expect_err("a cancelled adapter round must stop before prompt context");
253
254 assert!(matches!(error, AgentError::Cancelled));
255 assert!(running.messages.iter().any(|message| {
256 message.id == envelope.id.as_str()
257 && message.role == bamboo_domain::Role::User
258 && message.content == content
259 }));
260 assert!(!running
261 .metadata
262 .contains_key(crate::runtime::runner::prompt_context::PROMPT_MEMORY_OBSERVABILITY_KEY));
263 }
264
265 #[tokio::test]
266 async fn adapter_typed_inbox_input_drives_current_round_memory_recall() {
267 let directory = tempfile::tempdir().unwrap();
268 let store = Arc::new(
269 bamboo_storage::SessionStoreV2::new(directory.path().to_path_buf())
270 .await
271 .unwrap(),
272 );
273 let storage: Arc<dyn Storage> = store.clone();
274 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> =
275 Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
276 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
277 store,
278 SessionInboxLimits::default(),
279 ));
280
281 let memory = bamboo_memory::memory_store::MemoryStore::new(directory.path().join("jiandu"));
282 memory
283 .write_memory(
284 bamboo_memory::memory_store::MemoryScope::Global,
285 None,
286 bamboo_memory::memory_store::DurableMemoryType::Reference,
287 "Adapter silver heron rule",
288 "The silver heron request must use the lifecycle memory boundary.",
289 &["silver".to_string(), "heron".to_string()],
290 Some("adapter-typed-inbox-recall"),
291 "model",
292 false,
293 None,
294 )
295 .await
296 .unwrap();
297
298 let mut persisted = Session::new("adapter-typed-inbox-recall", "model");
299 persisted.add_message(Message::system("base prompt"));
300 persisted.add_message(Message::user("unrelated earlier request"));
301 storage.save_session(&persisted).await.unwrap();
302
303 let mut running = persisted;
304 let query = "what is the adapter silver heron rule?";
305 let envelope = SessionMessageEnvelope::user_input(&running.id, query);
306 let receipt = inbox.deliver(&envelope).await.unwrap();
307 inbox
308 .mark_activation_eligible(
309 &running.id,
310 receipt.generation,
311 SessionActivationPolicy::InterruptSpecificWait,
312 )
313 .await
314 .unwrap();
315 let config = AgentLoopConfig {
316 storage: Some(storage),
317 persistence: Some(persistence),
318 session_inbox: Some(inbox),
319 app_data_dir: Some(directory.path().join("bamboo")),
320 memory_store: memory,
321 prompt_memory_flags: crate::runtime::config::PromptMemoryFlags {
322 project_prompt_injection: false,
323 relevant_recall: true,
324 relevant_recall_rerank: false,
325 project_first_dream: false,
326 ledger_agenda: false,
327 },
328 model_name: Some("model".to_string()),
329 max_rounds: 1,
330 ..AgentLoopConfig::default()
331 };
332 let provider: Arc<dyn LLMProvider> = Arc::new(UnusedProvider);
333 let manager = DefaultLifecycleManager::new(provider.clone());
334 let mut runtime_state = manager.initialize_run(&running, &config);
335 let mut task_context = None;
336 let cancel = CancellationToken::new();
337 let session_id = running.id.clone();
338
339 manager
340 .prepare_round(
341 &mut running,
342 &mut task_context,
343 &mut runtime_state,
344 0,
345 1,
346 &config,
347 &cancel,
348 None,
349 &session_id,
350 "model",
351 &EmptyTools,
352 provider.as_ref(),
353 )
354 .await
355 .expect("typed inbox recall should prepare the current round");
356
357 assert!(running.messages.iter().any(|message| {
358 message.id == envelope.id.as_str()
359 && message.role == bamboo_domain::Role::User
360 && message.content == query
361 }));
362 let rendered =
363 crate::runtime::runner::prompt_context::render_external_memory_section(&running)
364 .expect("current-round recall should render external memory");
365 assert!(rendered.contains("Adapter silver heron rule"));
366 assert!(
367 rendered.contains("The silver heron request must use the lifecycle memory boundary.")
368 );
369
370 let observability: bamboo_agent_core::PromptMemoryObservability = serde_json::from_str(
371 running
372 .metadata
373 .get(crate::runtime::runner::prompt_context::PROMPT_MEMORY_OBSERVABILITY_KEY)
374 .expect("prompt memory refresh should persist observability"),
375 )
376 .unwrap();
377 assert!(observability.latest_user_query_present);
378 assert_eq!(observability.relevant_memory_status, "lexical");
379 assert_eq!(observability.relevant_memory_count, 1);
380 }
381}