1use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use futures::StreamExt;
17
18use bamboo_agent_core::Message;
19use bamboo_domain::reasoning::ReasoningEffort;
20use bamboo_llm::{Config, LLMChunk, LLMProvider, LLMRequestOptions, ProviderModelRouter};
21use bamboo_memory::auto_dream::{
22 build_blob_split_prompt, build_dedup_prompt, parse_dedup_decision, parse_split_pieces,
23};
24use bamboo_memory::memory_store::{
25 BlobScanItem, DuplicateCluster, DurableMemoryStatus, MemoryScope, MemoryStore,
26};
27
28use crate::auto_dream::AutoDreamContext;
29
30const GARDENER_TRACING_TARGET: &str = "bamboo.gardener";
31const GARDENER_RUNTIME_SESSION_ID: &str = "__gardener__";
32const DEDUP_MAX_MEMBERS_PER_CLUSTER: usize = 5;
35
36const BLOB_SYSTEM_INSTRUCTION: &str = "You are Bamboo's background memory gardener. Split the given memory into atomic pieces and return only the specified JSON. No prose, no markdown fences.";
37const DEDUP_SYSTEM_INSTRUCTION: &str = "You are Bamboo's background memory gardener. Decide whether the given memories are the same fact and, if so, consolidate them. Return only the specified JSON. No prose, no markdown fences.";
38
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct GardenerRunResult {
41 pub scanned: usize,
42 pub flagged: usize,
43 pub split: usize,
44 pub failed: usize,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct DedupGardenerRunResult {
49 pub scanned: usize,
50 pub clustered: usize,
51 pub consolidated: usize,
52 pub superseded: usize,
53 pub failed: usize,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct CapacityGardenerRunResult {
58 pub scopes_scanned: usize,
59 pub archived: usize,
60}
61
62async fn collect_model_json(
63 provider: Arc<dyn LLMProvider>,
64 model: &str,
65 system_instruction: &str,
66 prompt: String,
67) -> Result<String, String> {
68 let messages = vec![Message::system(system_instruction), Message::user(prompt)];
69 let options = LLMRequestOptions {
70 session_id: Some(GARDENER_RUNTIME_SESSION_ID.to_string()),
71 reasoning_effort: Some(ReasoningEffort::High),
72 parallel_tool_calls: None,
73 responses: None,
74 request_purpose: Some("memory_gardener".to_string()),
75 cache: None,
76 };
77
78 let mut stream = provider
79 .chat_stream_with_options(&messages, &[], Some(8192), model, Some(&options))
80 .await
81 .map_err(|error| format!("gardener provider call failed: {error}"))?;
82
83 let mut content = String::new();
84 while let Some(chunk) = stream.next().await {
85 match chunk {
86 Ok(LLMChunk::Token(text)) => content.push_str(&text),
87 Ok(LLMChunk::Done) => break,
88 Ok(_) => {}
89 Err(error) => {
90 if !content.is_empty() {
91 break;
92 }
93 return Err(format!("gardener stream failed: {error}"));
94 }
95 }
96 }
97 Ok(content)
98}
99
100fn resolve_background_model(
104 ctx: &AutoDreamContext,
105 config_snapshot: &Config,
106) -> Option<(Arc<dyn LLMProvider>, String)> {
107 let provider_ref_enabled = config_snapshot.features.provider_model_ref;
108 let model_ref = if provider_ref_enabled {
109 config_snapshot
110 .defaults
111 .as_ref()
112 .and_then(|d| d.memory_background.as_ref())
113 .or_else(|| {
114 config_snapshot
115 .defaults
116 .as_ref()
117 .and_then(|d| d.fast.as_ref())
118 })
119 } else {
120 None
121 };
122
123 if let Some(mr) = model_ref {
124 let router = ProviderModelRouter::new(ctx.provider_registry.clone());
125 match router.route(mr) {
126 Ok(routed) => Some((routed, mr.model.clone())),
127 Err(error) => {
128 tracing::warn!(
129 target: GARDENER_TRACING_TARGET,
130 event = "model_route_failed",
131 "[gardener] failed to route background model ref '{}': {}",
132 mr,
133 error
134 );
135 None
136 }
137 }
138 } else {
139 config_snapshot
140 .get_memory_background_model()
141 .map(|model| (ctx.provider.clone(), model))
142 }
143}
144
145pub async fn run_gardener_once(
146 ctx: &AutoDreamContext,
147) -> Result<Option<GardenerRunResult>, String> {
148 let memory = MemoryStore::new(ctx.session_store.bamboo_home_dir());
149 run_gardener_once_with_store(ctx, &memory).await
150}
151
152async fn run_gardener_once_with_store(
153 ctx: &AutoDreamContext,
154 memory: &MemoryStore,
155) -> Result<Option<GardenerRunResult>, String> {
156 let config_snapshot = ctx.config.read().await.clone();
157 let memory_cfg = config_snapshot.memory.clone().unwrap_or_default();
158 if !memory_cfg.gardener_enabled {
159 return Ok(None);
160 }
161
162 let max_splits = memory_cfg.gardener_max_splits_per_run.max(1);
163 let min_sections = memory_cfg.gardener_min_sections;
164
165 let mut targets: Vec<(MemoryScope, Option<String>)> = vec![(MemoryScope::Global, None)];
167 for key in memory.list_project_keys().await.unwrap_or_default() {
168 targets.push((MemoryScope::Project, Some(key)));
169 }
170
171 let mut result = GardenerRunResult::default();
172 let mut worklist: Vec<(MemoryScope, Option<String>, BlobScanItem)> = Vec::new();
173 for (scope, project_key) in &targets {
174 let report = memory
175 .scan_blob_candidates(*scope, project_key.as_deref(), min_sections, max_splits)
176 .await
177 .map_err(|error| format!("gardener scan failed: {error}"))?;
178 result.scanned += report.scanned;
179 result.flagged += report.flagged;
180 for item in report.items {
181 worklist.push((*scope, project_key.clone(), item));
182 }
183 }
184 worklist.sort_by(|left, right| {
185 right
186 .2
187 .appended_sections
188 .cmp(&left.2.appended_sections)
189 .then(right.2.body_chars.cmp(&left.2.body_chars))
190 });
191 worklist.truncate(max_splits);
192
193 if worklist.is_empty() {
195 return Ok(Some(result));
196 }
197
198 let Some((provider, model)) = resolve_background_model(ctx, &config_snapshot) else {
199 tracing::warn!(
200 target: GARDENER_TRACING_TARGET,
201 event = "run_skip",
202 reason = "no_background_model",
203 "[gardener] skipped: no background model configured"
204 );
205 return Ok(None);
206 };
207
208 for (_scope, project_key, item) in worklist {
209 let project_key = project_key.as_deref();
210 let Some(doc) = memory
211 .get_memory(&item.id, project_key)
212 .await
213 .map_err(|error| format!("gardener get failed: {error}"))?
214 else {
215 continue;
216 };
217
218 let prompt = build_blob_split_prompt(&doc.frontmatter.title, &doc.body);
219 let raw = match collect_model_json(
220 provider.clone(),
221 &model,
222 BLOB_SYSTEM_INSTRUCTION,
223 prompt,
224 )
225 .await
226 {
227 Ok(text) => text,
228 Err(error) => {
229 tracing::warn!(target: GARDENER_TRACING_TARGET, event = "split_llm_failed", id = %item.id, "{error}");
230 result.failed += 1;
231 continue;
232 }
233 };
234
235 let pieces = match parse_split_pieces(&raw) {
236 Ok(pieces) if pieces.len() >= 2 => pieces,
239 Ok(_) => continue,
240 Err(error) => {
241 tracing::warn!(target: GARDENER_TRACING_TARGET, event = "split_parse_failed", id = %item.id, "{error}");
242 result.failed += 1;
243 continue;
244 }
245 };
246
247 match memory
248 .split_memory(
249 &item.id,
250 project_key,
251 &pieces,
252 Some(GARDENER_RUNTIME_SESSION_ID),
253 "memory-gardener",
254 )
255 .await
256 {
257 Ok(Some(_)) => result.split += 1,
258 Ok(None) => {}
259 Err(error) => {
260 tracing::warn!(target: GARDENER_TRACING_TARGET, event = "split_apply_failed", id = %item.id, "{error}");
261 result.failed += 1;
262 }
263 }
264 }
265
266 tracing::info!(
267 target: GARDENER_TRACING_TARGET,
268 event = "run_complete",
269 scanned = result.scanned,
270 flagged = result.flagged,
271 split = result.split,
272 failed = result.failed,
273 "[gardener] run complete"
274 );
275 Ok(Some(result))
276}
277
278pub async fn run_dedup_gardener_once(
279 ctx: &AutoDreamContext,
280) -> Result<Option<DedupGardenerRunResult>, String> {
281 let memory = MemoryStore::new(ctx.session_store.bamboo_home_dir());
282 run_dedup_gardener_once_with_store(ctx, &memory).await
283}
284
285async fn run_dedup_gardener_once_with_store(
286 ctx: &AutoDreamContext,
287 memory: &MemoryStore,
288) -> Result<Option<DedupGardenerRunResult>, String> {
289 let config_snapshot = ctx.config.read().await.clone();
290 let memory_cfg = config_snapshot.memory.clone().unwrap_or_default();
291 if !memory_cfg.dedup_gardener_enabled {
292 return Ok(None);
293 }
294
295 let max_merges = memory_cfg.dedup_gardener_max_merges_per_run.max(1);
296 let min_score = memory_cfg.dedup_gardener_min_score;
297
298 let mut targets: Vec<(MemoryScope, Option<String>)> = vec![(MemoryScope::Global, None)];
300 for key in memory.list_project_keys().await.unwrap_or_default() {
301 targets.push((MemoryScope::Project, Some(key)));
302 }
303
304 let mut result = DedupGardenerRunResult::default();
305 let mut worklist: Vec<(MemoryScope, Option<String>, DuplicateCluster)> = Vec::new();
306 for (scope, project_key) in &targets {
307 let report = memory
308 .scan_duplicate_clusters(
309 *scope,
310 project_key.as_deref(),
311 min_score,
312 DEDUP_MAX_MEMBERS_PER_CLUSTER,
313 max_merges,
314 )
315 .await
316 .map_err(|error| format!("dedup scan failed: {error}"))?;
317 result.scanned += report.scanned;
318 result.clustered += report.clusters.len();
319 for cluster in report.clusters {
320 worklist.push((*scope, project_key.clone(), cluster));
321 }
322 }
323 worklist.sort_by(|left, right| {
324 right
325 .2
326 .max_score
327 .partial_cmp(&left.2.max_score)
328 .unwrap_or(std::cmp::Ordering::Equal)
329 .then(right.2.members.len().cmp(&left.2.members.len()))
330 });
331 worklist.truncate(max_merges);
332
333 if worklist.is_empty() {
335 return Ok(Some(result));
336 }
337
338 let Some((provider, model)) = resolve_background_model(ctx, &config_snapshot) else {
339 tracing::warn!(
340 target: GARDENER_TRACING_TARGET,
341 event = "dedup_run_skip",
342 reason = "no_background_model",
343 "[dedup-gardener] skipped: no background model configured"
344 );
345 return Ok(None);
346 };
347
348 for (_scope, project_key, cluster) in worklist {
349 let project_key = project_key.as_deref();
350 let mut members: Vec<(String, String, String)> = Vec::new();
353 for member in &cluster.members {
354 if let Some(doc) = memory
355 .get_memory(&member.id, project_key)
356 .await
357 .map_err(|error| format!("dedup get failed: {error}"))?
358 {
359 if doc.frontmatter.status == DurableMemoryStatus::Active {
360 members.push((
361 doc.frontmatter.id.clone(),
362 doc.frontmatter.title.clone(),
363 doc.body.clone(),
364 ));
365 }
366 }
367 }
368 if members.len() < 2 {
369 continue;
370 }
371
372 let prompt_members: Vec<(String, String)> = members
373 .iter()
374 .map(|(_, title, body)| (title.clone(), body.clone()))
375 .collect();
376 let prompt = build_dedup_prompt(&prompt_members);
377 let raw = match collect_model_json(
378 provider.clone(),
379 &model,
380 DEDUP_SYSTEM_INSTRUCTION,
381 prompt,
382 )
383 .await
384 {
385 Ok(text) => text,
386 Err(error) => {
387 tracing::warn!(target: GARDENER_TRACING_TARGET, event = "dedup_llm_failed", "{error}");
388 result.failed += 1;
389 continue;
390 }
391 };
392
393 let merged = match parse_dedup_decision(&raw) {
394 Ok(Some(piece)) => piece,
396 Ok(None) => continue,
397 Err(error) => {
398 tracing::warn!(target: GARDENER_TRACING_TARGET, event = "dedup_parse_failed", "{error}");
399 result.failed += 1;
400 continue;
401 }
402 };
403
404 let ids: Vec<String> = members.iter().map(|(id, _, _)| id.clone()).collect();
405 match memory
406 .consolidate_memories(
407 &ids,
408 project_key,
409 &merged,
410 Some(GARDENER_RUNTIME_SESSION_ID),
411 "memory-dedup-gardener",
412 )
413 .await
414 {
415 Ok(Some(_)) => {
416 result.consolidated += 1;
417 result.superseded += ids.len();
418 }
419 Ok(None) => {}
420 Err(error) => {
421 tracing::warn!(target: GARDENER_TRACING_TARGET, event = "dedup_apply_failed", "{error}");
422 result.failed += 1;
423 }
424 }
425 }
426
427 tracing::info!(
428 target: GARDENER_TRACING_TARGET,
429 event = "dedup_run_complete",
430 scanned = result.scanned,
431 clustered = result.clustered,
432 consolidated = result.consolidated,
433 superseded = result.superseded,
434 failed = result.failed,
435 "[dedup-gardener] run complete"
436 );
437 Ok(Some(result))
438}
439
440pub async fn run_capacity_gardener_once(
446 ctx: &AutoDreamContext,
447) -> Result<Option<CapacityGardenerRunResult>, String> {
448 let memory = MemoryStore::new(ctx.session_store.bamboo_home_dir());
449 run_capacity_gardener_once_with_store(ctx, &memory).await
450}
451
452async fn run_capacity_gardener_once_with_store(
453 ctx: &AutoDreamContext,
454 memory: &MemoryStore,
455) -> Result<Option<CapacityGardenerRunResult>, String> {
456 let memory_cfg = ctx.config.read().await.memory.clone().unwrap_or_default();
457 let capacity = memory_cfg.memory_active_capacity;
458 if capacity == 0 {
459 return Ok(None);
460 }
461 let max_archivals = memory_cfg.capacity_max_archivals_per_run.max(1);
462
463 let mut targets: Vec<(MemoryScope, Option<String>)> = vec![(MemoryScope::Global, None)];
464 for key in memory.list_project_keys().await.unwrap_or_default() {
465 targets.push((MemoryScope::Project, Some(key)));
466 }
467
468 let mut result = CapacityGardenerRunResult::default();
469 for (scope, project_key) in &targets {
470 let archived = memory
471 .enforce_scope_capacity(*scope, project_key.as_deref(), capacity, max_archivals)
472 .await
473 .map_err(|error| format!("capacity enforcement failed: {error}"))?;
474 result.scopes_scanned += 1;
475 result.archived += archived.len();
476 }
477 if result.archived > 0 {
478 tracing::info!(
479 target: GARDENER_TRACING_TARGET,
480 event = "capacity_run_complete",
481 scopes_scanned = result.scopes_scanned,
482 archived = result.archived,
483 capacity = capacity,
484 "[capacity-gardener] archived {} memories over capacity",
485 result.archived
486 );
487 }
488 Ok(Some(result))
489}
490
491const GARDENER_VOLUME_POLL_SECS: u64 = 300;
496
497fn should_run_gardener_pass(
502 elapsed_secs: u64,
503 interval_secs: u64,
504 current_count: usize,
505 last_run_count: usize,
506 volume_trigger: usize,
507) -> bool {
508 if elapsed_secs >= interval_secs {
509 return true;
510 }
511 volume_trigger > 0 && current_count.saturating_sub(last_run_count) >= volume_trigger
512}
513
514async fn run_gardener_passes(ctx: &AutoDreamContext) {
518 if let Err(error) = run_gardener_once(ctx).await {
519 tracing::warn!(
520 target: GARDENER_TRACING_TARGET,
521 event = "run_failed",
522 "[gardener] run failed: {}",
523 error
524 );
525 }
526 if let Err(error) = run_dedup_gardener_once(ctx).await {
527 tracing::warn!(
528 target: GARDENER_TRACING_TARGET,
529 event = "dedup_run_failed",
530 "[dedup-gardener] run failed: {}",
531 error
532 );
533 }
534 if let Err(error) = run_capacity_gardener_once(ctx).await {
536 tracing::warn!(
537 target: GARDENER_TRACING_TARGET,
538 event = "capacity_run_failed",
539 "[capacity-gardener] run failed: {}",
540 error
541 );
542 }
543}
544
545pub fn spawn_gardener_task(ctx: AutoDreamContext) {
551 tokio::spawn(async move {
552 let (interval_secs, volume_trigger) = {
553 let guard = ctx.config.read().await;
554 let memory = guard.memory.as_ref();
555 let interval_secs = memory
556 .map(|memory| memory.gardener_interval_secs)
557 .filter(|secs| *secs > 0)
558 .unwrap_or_else(|| bamboo_config::MemoryConfig::default().gardener_interval_secs);
561 let volume_trigger = memory
562 .map(|memory| memory.gardener_volume_trigger)
563 .unwrap_or_else(|| bamboo_config::MemoryConfig::default().gardener_volume_trigger);
564 (interval_secs, volume_trigger)
565 };
566 let poll_secs = GARDENER_VOLUME_POLL_SECS.min(interval_secs.max(1));
569
570 let memory = MemoryStore::new(ctx.session_store.bamboo_home_dir());
571 let mut ticker = tokio::time::interval(Duration::from_secs(poll_secs));
572 let mut last_run = Instant::now();
573 let mut last_run_count = memory.count_all_memories().await.unwrap_or(0);
574 let mut force_first = true;
577
578 loop {
579 ticker.tick().await;
580 let current_count = memory.count_all_memories().await.unwrap_or(last_run_count);
581 let elapsed_secs = last_run.elapsed().as_secs();
582 if !force_first
583 && !should_run_gardener_pass(
584 elapsed_secs,
585 interval_secs,
586 current_count,
587 last_run_count,
588 volume_trigger,
589 )
590 {
591 continue;
592 }
593 force_first = false;
594 run_gardener_passes(&ctx).await;
595 last_run = Instant::now();
596 last_run_count = memory.count_all_memories().await.unwrap_or(current_count);
602 }
603 });
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 use std::collections::HashMap;
611 use std::sync::Mutex;
612
613 use async_trait::async_trait;
614 use futures::stream;
615 use tokio::sync::RwLock;
616
617 use bamboo_agent_core::storage::Storage;
618 use bamboo_llm::{LLMError, LLMStream, ProviderRegistry};
619 use bamboo_memory::memory_store::DurableMemoryType;
620 use bamboo_storage::SessionStoreV2;
621
622 #[test]
623 fn gardener_trigger_fires_on_time_or_volume() {
624 assert!(should_run_gardener_pass(86_400, 86_400, 0, 0, 25));
626 assert!(should_run_gardener_pass(90_000, 86_400, 5, 5, 25));
627 assert!(!should_run_gardener_pass(60, 86_400, 30, 20, 25));
629 assert!(should_run_gardener_pass(60, 86_400, 45, 20, 25));
631 assert!(should_run_gardener_pass(60, 86_400, 25, 0, 25));
632 assert!(!should_run_gardener_pass(60, 86_400, 44, 20, 25));
634 assert!(!should_run_gardener_pass(60, 86_400, 10_000, 0, 0));
636 assert!(!should_run_gardener_pass(60, 86_400, 5, 100, 25));
638 }
639
640 #[derive(Clone)]
641 struct CannedProvider {
642 responses: Arc<Mutex<Vec<String>>>,
643 }
644
645 impl CannedProvider {
646 fn new(responses: Vec<String>) -> Self {
647 Self {
648 responses: Arc::new(Mutex::new(responses)),
649 }
650 }
651 }
652
653 #[async_trait]
654 impl LLMProvider for CannedProvider {
655 async fn chat_stream(
656 &self,
657 _messages: &[Message],
658 _tools: &[bamboo_agent_core::tools::ToolSchema],
659 _max_output_tokens: Option<u32>,
660 _model: &str,
661 ) -> Result<LLMStream, LLMError> {
662 let mut responses = self.responses.lock().expect("lock poisoned");
663 let text = if responses.is_empty() {
664 "{\"pieces\":[]}".to_string()
665 } else {
666 responses.remove(0)
667 };
668 Ok(Box::pin(stream::iter(vec![
669 Ok(LLMChunk::Token(text)),
670 Ok(LLMChunk::Done),
671 ])))
672 }
673 }
674
675 #[tokio::test]
676 async fn gardener_splits_a_global_blob_and_is_capped() {
677 let temp = tempfile::tempdir().expect("tempdir");
678 bamboo_config::paths::init_bamboo_dir(temp.path().to_path_buf());
679
680 let session_store = Arc::new(
681 SessionStoreV2::new(temp.path().to_path_buf())
682 .await
683 .unwrap(),
684 );
685 let storage: Arc<dyn Storage> = session_store.clone();
686 let provider: Arc<dyn LLMProvider> = Arc::new(CannedProvider::new(vec![
687 "{\"pieces\":[{\"title\":\"Fact one\",\"type\":\"user\",\"content\":\"Fact one body.\",\"tags\":[]},{\"title\":\"Fact two\",\"type\":\"reference\",\"content\":\"Fact two body.\",\"tags\":[]}]}".to_string(),
688 ]));
689 let config = Arc::new(RwLock::new(Config {
690 memory: Some(bamboo_config::MemoryConfig {
691 background_model: Some("fast-model".to_string()),
692 gardener_enabled: true,
693 gardener_min_sections: 2,
694 gardener_max_splits_per_run: 8,
695 ..bamboo_config::MemoryConfig::default()
696 }),
697 ..Config::default()
698 }));
699 let provider_registry = Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()));
700
701 let ctx = AutoDreamContext {
702 session_store: session_store.clone(),
703 storage,
704 provider,
705 config,
706 provider_registry,
707 };
708
709 let memory = MemoryStore::new(session_store.bamboo_home_dir());
711 let blob = memory
712 .write_memory(
713 MemoryScope::Global,
714 None,
715 DurableMemoryType::User,
716 "mixed blob",
717 "fact one",
718 &[],
719 Some("s"),
720 "t",
721 false,
722 None,
723 )
724 .await
725 .unwrap();
726 for extra in ["fact two", "fact three", "fact four"] {
727 memory
728 .merge_memory(&blob.frontmatter.id, None, extra, &[], Some("s"), "t", &[])
729 .await
730 .unwrap();
731 }
732
733 let result = run_gardener_once(&ctx).await.unwrap().unwrap();
734 assert_eq!(result.split, 1);
735
736 let source = memory
737 .get_memory(&blob.frontmatter.id, None)
738 .await
739 .unwrap()
740 .unwrap();
741 assert_eq!(
742 source.frontmatter.status,
743 bamboo_memory::memory_store::DurableMemoryStatus::Superseded
744 );
745 }
746
747 #[tokio::test]
748 async fn gardener_is_noop_when_disabled() {
749 let temp = tempfile::tempdir().expect("tempdir");
750 bamboo_config::paths::init_bamboo_dir(temp.path().to_path_buf());
751 let session_store = Arc::new(
752 SessionStoreV2::new(temp.path().to_path_buf())
753 .await
754 .unwrap(),
755 );
756 let storage: Arc<dyn Storage> = session_store.clone();
757 let provider: Arc<dyn LLMProvider> = Arc::new(CannedProvider::new(vec![]));
758 let config = Arc::new(RwLock::new(Config {
761 memory: Some(bamboo_config::MemoryConfig {
762 gardener_enabled: false,
763 ..bamboo_config::MemoryConfig::default()
764 }),
765 ..Config::default()
766 }));
767 let provider_registry = Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()));
768 let ctx = AutoDreamContext {
769 session_store,
770 storage,
771 provider,
772 config,
773 provider_registry,
774 };
775 assert_eq!(run_gardener_once(&ctx).await.unwrap(), None);
776 }
777
778 #[tokio::test]
779 async fn dedup_gardener_consolidates_a_near_duplicate_pair() {
780 let temp = tempfile::tempdir().expect("tempdir");
781 bamboo_config::paths::init_bamboo_dir(temp.path().to_path_buf());
782
783 let session_store = Arc::new(
784 SessionStoreV2::new(temp.path().to_path_buf())
785 .await
786 .unwrap(),
787 );
788 let storage: Arc<dyn Storage> = session_store.clone();
789 let provider: Arc<dyn LLMProvider> = Arc::new(CannedProvider::new(vec![
790 "{\"same_fact\":true,\"merged\":{\"title\":\"Mobile release freeze is Tuesday\",\"type\":\"project\",\"content\":\"Mobile release freeze begins Tuesday for the release cut.\",\"tags\":[\"release\"]}}".to_string(),
791 ]));
792 let config = Arc::new(RwLock::new(Config {
793 memory: Some(bamboo_config::MemoryConfig {
794 background_model: Some("fast-model".to_string()),
795 dedup_gardener_enabled: true,
796 dedup_gardener_min_score: 0.3,
797 dedup_gardener_max_merges_per_run: 8,
798 ..bamboo_config::MemoryConfig::default()
799 }),
800 ..Config::default()
801 }));
802 let provider_registry = Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()));
803
804 let ctx = AutoDreamContext {
805 session_store: session_store.clone(),
806 storage,
807 provider,
808 config,
809 provider_registry,
810 };
811
812 let memory = MemoryStore::new(session_store.bamboo_home_dir());
814 let mut ids = Vec::new();
815 for (title, content) in [
816 (
817 "freeze v1",
818 "Mobile release freeze begins Tuesday for the cut.",
819 ),
820 (
821 "freeze v2",
822 "Mobile release freeze starts Tuesday for the release cut.",
823 ),
824 ] {
825 let doc = memory
826 .write_memory(
827 MemoryScope::Global,
828 None,
829 DurableMemoryType::Project,
830 title,
831 content,
832 &[],
833 Some("s"),
834 "t",
835 false,
836 None,
837 )
838 .await
839 .unwrap();
840 ids.push(doc.frontmatter.id);
841 }
842
843 let result = run_dedup_gardener_once(&ctx).await.unwrap().unwrap();
844 assert_eq!(result.consolidated, 1);
845 assert_eq!(result.superseded, 2);
846
847 for id in &ids {
849 let source = memory.get_memory(id, None).await.unwrap().unwrap();
850 assert_eq!(
851 source.frontmatter.status,
852 bamboo_memory::memory_store::DurableMemoryStatus::Superseded
853 );
854 }
855 }
856
857 #[tokio::test]
858 async fn dedup_gardener_is_noop_when_disabled() {
859 let temp = tempfile::tempdir().expect("tempdir");
860 bamboo_config::paths::init_bamboo_dir(temp.path().to_path_buf());
861 let session_store = Arc::new(
862 SessionStoreV2::new(temp.path().to_path_buf())
863 .await
864 .unwrap(),
865 );
866 let storage: Arc<dyn Storage> = session_store.clone();
867 let provider: Arc<dyn LLMProvider> = Arc::new(CannedProvider::new(vec![]));
868 let config = Arc::new(RwLock::new(Config {
870 memory: Some(bamboo_config::MemoryConfig {
871 dedup_gardener_enabled: false,
872 ..bamboo_config::MemoryConfig::default()
873 }),
874 ..Config::default()
875 }));
876 let provider_registry = Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()));
877 let ctx = AutoDreamContext {
878 session_store,
879 storage,
880 provider,
881 config,
882 provider_registry,
883 };
884 assert_eq!(run_dedup_gardener_once(&ctx).await.unwrap(), None);
885 }
886
887 #[tokio::test]
890 async fn capacity_gardener_off_until_capacity_set_then_archives_overflow() {
891 let temp = tempfile::tempdir().expect("tempdir");
892 bamboo_config::paths::init_bamboo_dir(temp.path().to_path_buf());
893 let session_store = Arc::new(
894 SessionStoreV2::new(temp.path().to_path_buf())
895 .await
896 .unwrap(),
897 );
898 let storage: Arc<dyn Storage> = session_store.clone();
899 let provider: Arc<dyn LLMProvider> = Arc::new(CannedProvider::new(vec![]));
900
901 let memory = MemoryStore::new(session_store.bamboo_home_dir());
902 for title in [
903 "Global fact a",
904 "Global fact b",
905 "Global fact c",
906 "Global fact d",
907 ] {
908 memory
909 .write_memory(
910 MemoryScope::Global,
911 None,
912 DurableMemoryType::Project,
913 title,
914 "body content for the memory",
915 &[],
916 Some("s"),
917 "m",
918 false,
919 None,
920 )
921 .await
922 .unwrap();
923 }
924
925 let off_config = Arc::new(RwLock::new(Config::default()));
927 let provider_registry = Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()));
928 let ctx_off = AutoDreamContext {
929 session_store: session_store.clone(),
930 storage: storage.clone(),
931 provider: provider.clone(),
932 config: off_config,
933 provider_registry: provider_registry.clone(),
934 };
935 assert_eq!(
936 run_capacity_gardener_once_with_store(&ctx_off, &memory)
937 .await
938 .unwrap(),
939 None
940 );
941
942 let on_config = Arc::new(RwLock::new(Config {
944 memory: Some(bamboo_config::MemoryConfig {
945 memory_active_capacity: 2,
946 ..bamboo_config::MemoryConfig::default()
947 }),
948 ..Config::default()
949 }));
950 let ctx_on = AutoDreamContext {
951 session_store,
952 storage,
953 provider,
954 config: on_config,
955 provider_registry,
956 };
957 let result = run_capacity_gardener_once_with_store(&ctx_on, &memory)
958 .await
959 .unwrap()
960 .expect("capacity pass should run when enabled");
961 assert_eq!(result.archived, 2, "archived the over-capacity overflow");
962 assert_eq!(
963 memory
964 .count_scope_memories(MemoryScope::Global, None)
965 .await
966 .unwrap(),
967 4,
968 "archived docs are kept on disk, not deleted"
969 );
970 }
971}