Skip to main content

bamboo_engine/
gardener.rs

1//! Background "gardener": LLM-driven remediation of memory-quality problems.
2//!
3//! Two symmetric passes share one cadence and one cost model:
4//! - the **blob gardener** splits multi-topic "blob" memories into atomic pieces;
5//! - the **dedup gardener** consolidates near-duplicate memories into one.
6//!
7//! Cost-controlled by design: each pass is opt-in (default off), has a hard per-run
8//! cap, runs on a slow cadence, and — crucially — makes ZERO LLM calls when its
9//! deterministic prefilter finds nothing (an idle gardener costs nothing). The
10//! *decision* needs the model; the *worklist* (which memories are blobs / which are
11//! near-duplicates) is produced for free by `MemoryStore` scan methods.
12
13use 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__";
32/// Upper bound on how many memories the dedup gardener feeds to the model per
33/// cluster, to bound prompt size and avoid over-merging a large fuzzy group.
34const 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
100/// Mirrors auto_dream's background-model resolution (ProviderModelRef when enabled,
101/// else `memory.background_model` / provider fast model). Returns `None` when no
102/// background model is configured — the gardener then skips without spending tokens.
103fn 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    // Deterministic prefilter across global + every project scope (zero LLM).
166    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    // Nothing to do → return WITHOUT resolving/calling the model. Idle = free.
194    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            // Require ≥2 pieces: a 0/1-piece answer means the model judged it not a
237            // blob, so we leave it untouched rather than churn it.
238            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    // Deterministic prefilter across global + every project scope (zero LLM).
299    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    // Nothing to do → return WITHOUT resolving/calling the model. Idle = free.
334    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        // Re-fetch full bodies; drop members that vanished or were superseded since
351        // the scan (e.g. by the blob pass) so we never consolidate a stale set.
352        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            // `None` = model judged them distinct facts; leave them untouched.
395            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
440/// Capacity gardener (L5): bound each scope's RECALLABLE size by archiving the
441/// lowest-value overflow OUT of the recall index — never deletes (reversible), no
442/// LLM. Off unless `memory_active_capacity > 0`; `Reference`/`User`/`Feedback`
443/// memories are exempt. Runs last (after split + dedup), so it counts the
444/// post-consolidation library. `Ok(None)` when the feature is off.
445pub 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
491/// How often the gardener loop polls for the volume trigger. Kept short relative
492/// to the (typically daily) time interval so library growth is caught within
493/// minutes, not a full interval. Each poll is a cheap count + a config read; the
494/// actual passes only run when the time or volume condition fires.
495const GARDENER_VOLUME_POLL_SECS: u64 = 300;
496
497/// Decide whether the gardener maintenance pass should run on this poll: on the
498/// time interval, OR early once the library has grown by `volume_trigger` memories
499/// since the last run (L4). `volume_trigger == 0` disables the volume trigger
500/// (time-only). Pure so the trigger logic is unit-testable without wall-clock.
501fn 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
514/// Run both gardener passes in order. Blob remediation first, then dedup: splitting
515/// a blob can expose fresh duplicates, and superseded blob sources drop out of the
516/// dedup scan.
517async 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    // Capacity enforcement last, so it counts the post-consolidation library.
535    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
545/// Spawn the recurring gardener loop. Time- AND volume-triggered (L4): it polls on
546/// a short cadence and runs the passes when either the configured interval elapsed
547/// or enough new memories accumulated since the last run. No-op cost when disabled:
548/// each pass reads config and returns immediately if its gardener is off, and the
549/// poll itself is just a cheap topic-file count.
550pub 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                // Fall back to the config default (single source of truth for
559                // "daily") when memory config is absent or the interval was 0.
560                .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        // Poll frequently enough to catch volume growth, but never longer than the
567        // time interval itself.
568        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        // Run once on startup (the first `interval` tick fired immediately before
575        // this change), then let the time/volume conditions drive it.
576        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            // Re-baseline the count AFTER the pass so the gardener's own file writes
597            // don't re-trigger it. Superseded/archived sources are kept as tombstone
598            // files, so a split or a dedup-consolidation actually GROWS the topic-file
599            // count (a new canonical is written, sources retained); only a hard purge
600            // shrinks it. Re-baselining absorbs all of that.
601            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        // Time trigger: elapsed >= interval fires regardless of growth.
625        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        // Not yet due and not enough growth → no run.
628        assert!(!should_run_gardener_pass(60, 86_400, 30, 20, 25));
629        // Volume trigger: grew by >= threshold before the interval → early run.
630        assert!(should_run_gardener_pass(60, 86_400, 45, 20, 25));
631        assert!(should_run_gardener_pass(60, 86_400, 25, 0, 25));
632        // Growth below the threshold → no run.
633        assert!(!should_run_gardener_pass(60, 86_400, 44, 20, 25));
634        // volume_trigger == 0 disables the volume trigger (time-only).
635        assert!(!should_run_gardener_pass(60, 86_400, 10_000, 0, 0));
636        // Count shrinking (e.g. after a dedup) never underflows into a trigger.
637        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        // Seed a blob with 3 `---` accretions in the same bamboo home the gardener reads.
710        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        // Gardener is ON by default (L4), so disable it explicitly to test the
759        // disabled path.
760        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        // Seed two near-duplicate memories in the same bamboo home the gardener reads.
813        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        // Both originals are superseded by a new canonical memory.
848        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        // Dedup gardener is ON by default (L4); disable it explicitly here.
869        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    /// L5: the capacity gardener is off (Ok(None)) unless `memory_active_capacity`
888    /// is set, and when set it archives the over-capacity overflow.
889    #[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        // Off by default (capacity == 0) → Ok(None), archives nothing.
926        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        // Capacity 2 → archive the 2 over-capacity memories.
943        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}