Skip to main content

bamboo_engine/runtime/managers/adapters/
lifecycle.rs

1use 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
17/// Default lifecycle manager that delegates to existing runner functions.
18pub 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        // `AgentRuntimeState::run_id` is the existing per-execution identity on
32        // this adapter path. Give every initialized run a fresh value so round
33        // counters can safely restart for the same session.
34        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, // debug logging handled at runner level, not via adapter
74                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}