Skip to main content

bamboo_memory/memory_store/
recall.rs

1use std::cmp::Ordering;
2use std::collections::HashSet;
3use std::io;
4use std::sync::Arc;
5
6use futures::StreamExt;
7use serde::Deserialize;
8
9use bamboo_agent_core::Message;
10use bamboo_domain::ReasoningEffort;
11use bamboo_llm::{LLMChunk, LLMProvider, LLMRequestOptions};
12
13use super::{parse_rfc3339, DurableMemoryStatus, MemoryScope, MemoryStore, TemporalGranularity};
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct MemoryRecallCandidate {
17    pub id: String,
18    pub title: String,
19    pub score: f64,
20    pub scope: MemoryScope,
21    pub project_key: Option<String>,
22    pub status: DurableMemoryStatus,
23    pub updated_at: String,
24    pub summary: String,
25    /// Optional temporal granularity, used as a stable tie-breaker in
26    /// [`sort_recall_candidates`]: among equally-relevant candidates, coarser
27    /// (more cache-stable) memories sort first. `None` is treated as most stable.
28    pub granularity: Option<TemporalGranularity>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct MemoryRecallOptions {
33    pub shortlist_limit: usize,
34    pub include_global_fallback: bool,
35    pub max_candidates_per_scope: usize,
36}
37
38impl Default for MemoryRecallOptions {
39    fn default() -> Self {
40        Self {
41            shortlist_limit: 3,
42            include_global_fallback: true,
43            max_candidates_per_scope: 20,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum MemoryRecallStrategy {
50    Lexical,
51    Reranked,
52    RerankFallback,
53}
54
55impl MemoryRecallStrategy {
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Self::Lexical => "lexical",
59            Self::Reranked => "reranked",
60            Self::RerankFallback => "rerank_fallback",
61        }
62    }
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct MemoryRecallSelection {
67    pub candidates: Vec<MemoryRecallCandidate>,
68    pub strategy: MemoryRecallStrategy,
69}
70
71#[derive(Clone)]
72pub struct MemoryRecallRerankContext {
73    pub llm: Arc<dyn LLMProvider>,
74    pub model: String,
75    pub session_id: Option<String>,
76}
77
78#[derive(Debug, Deserialize)]
79struct MemoryRecallRerankEnvelope {
80    #[serde(default)]
81    ids: Vec<String>,
82}
83
84pub async fn shortlist_relevant_memories(
85    store: &MemoryStore,
86    project_key: Option<&str>,
87    query: &str,
88    options: &MemoryRecallOptions,
89) -> io::Result<Vec<MemoryRecallCandidate>> {
90    let limit = options.shortlist_limit.max(1);
91    let mut candidates =
92        lexical_shortlist_relevant_memories(store, project_key, query, options).await?;
93    candidates.truncate(limit);
94    Ok(candidates)
95}
96
97pub async fn select_relevant_memories(
98    store: &MemoryStore,
99    project_key: Option<&str>,
100    query: &str,
101    options: &MemoryRecallOptions,
102    rerank_context: Option<&MemoryRecallRerankContext>,
103) -> io::Result<MemoryRecallSelection> {
104    let query = query.trim();
105    if query.is_empty() {
106        return Ok(MemoryRecallSelection {
107            candidates: Vec::new(),
108            strategy: MemoryRecallStrategy::Lexical,
109        });
110    }
111
112    let limit = options.shortlist_limit.max(1);
113    let mut shortlist =
114        lexical_shortlist_relevant_memories(store, project_key, query, options).await?;
115    if shortlist.is_empty() {
116        return Ok(MemoryRecallSelection {
117            candidates: shortlist,
118            strategy: MemoryRecallStrategy::Lexical,
119        });
120    }
121
122    let Some(rerank_context) = rerank_context else {
123        shortlist.truncate(limit);
124        return Ok(MemoryRecallSelection {
125            candidates: shortlist,
126            strategy: MemoryRecallStrategy::Lexical,
127        });
128    };
129
130    if shortlist.len() <= 1 {
131        shortlist.truncate(limit);
132        return Ok(MemoryRecallSelection {
133            candidates: shortlist,
134            strategy: MemoryRecallStrategy::Lexical,
135        });
136    }
137
138    match rerank_candidate_ids(query, &shortlist, limit, rerank_context).await {
139        // A VALID but empty selection means the reranker judged NONE of the lexical
140        // shortlist relevant to the query. Respect that — return no memories rather
141        // than resurrecting the noisy shortlist (which otherwise surfaces unrelated
142        // memories the reranker explicitly rejected). This is NOT a failure, so it
143        // doesn't warn or fall back — only a genuine rerank error (Err) does.
144        Ok(ids) if ids.is_empty() => Ok(MemoryRecallSelection {
145            candidates: Vec::new(),
146            strategy: MemoryRecallStrategy::Reranked,
147        }),
148        Ok(ids) => Ok(MemoryRecallSelection {
149            candidates: reorder_candidates_by_ids(&shortlist, &ids, limit),
150            strategy: MemoryRecallStrategy::Reranked,
151        }),
152        Err(error) => {
153            tracing::warn!(
154                "Relevant memory rerank failed for model '{}': {}. Falling back to lexical shortlist.",
155                rerank_context.model,
156                error
157            );
158            shortlist.truncate(limit);
159            Ok(MemoryRecallSelection {
160                candidates: shortlist,
161                strategy: MemoryRecallStrategy::RerankFallback,
162            })
163        }
164    }
165}
166
167async fn lexical_shortlist_relevant_memories(
168    store: &MemoryStore,
169    project_key: Option<&str>,
170    query: &str,
171    options: &MemoryRecallOptions,
172) -> io::Result<Vec<MemoryRecallCandidate>> {
173    let query = query.trim();
174    if query.is_empty() {
175        return Ok(Vec::new());
176    }
177
178    let limit = options.shortlist_limit.max(1);
179    let per_scope_limit = options.max_candidates_per_scope.max(limit);
180
181    if let Some(project_key) = project_key.map(str::trim).filter(|value| !value.is_empty()) {
182        let mut project_hits =
183            shortlist_scope(store, MemoryScope::Project, Some(project_key), query).await?;
184        project_hits.truncate(per_scope_limit);
185        if !project_hits.is_empty() {
186            return Ok(project_hits);
187        }
188    }
189
190    if options.include_global_fallback {
191        let mut global_hits = shortlist_scope(store, MemoryScope::Global, None, query).await?;
192        global_hits.truncate(per_scope_limit);
193        return Ok(global_hits);
194    }
195
196    Ok(Vec::new())
197}
198
199async fn shortlist_scope(
200    store: &MemoryStore,
201    scope: MemoryScope,
202    project_key: Option<&str>,
203    query: &str,
204) -> io::Result<Vec<MemoryRecallCandidate>> {
205    let Some(index) = store.read_lexical_index(scope, project_key).await? else {
206        return Ok(Vec::new());
207    };
208
209    let query_tokens = super::lexical_bm25::tokenize(query);
210    if query_tokens.is_empty() {
211        return Ok(Vec::new());
212    }
213
214    // BM25(F) over the loaded lexical index: corpus stats (df, avgdl) are computed
215    // once, then each doc is scored in O(query terms). CJK-aware tokenization makes
216    // the bilingual library searchable — the previous tokenizer dropped all Chinese.
217    let corpus = super::lexical_bm25::Bm25Corpus::build(&index.items);
218    let mut candidates = index
219        .items
220        .iter()
221        .enumerate()
222        .filter_map(|(i, item)| corpus.score(i, &query_tokens).map(|score| (item, score)))
223        .map(|(item, score)| MemoryRecallCandidate {
224            id: item.id.clone(),
225            title: item.title.clone(),
226            score,
227            scope: item.scope,
228            project_key: item.project_key.clone(),
229            status: item.status,
230            updated_at: item.updated_at.clone(),
231            summary: item.summary.clone(),
232            granularity: item.granularity,
233        })
234        .collect::<Vec<_>>();
235
236    sort_recall_candidates(&mut candidates);
237    Ok(candidates)
238}
239
240fn sort_recall_candidates(candidates: &mut [MemoryRecallCandidate]) {
241    candidates.sort_by(|left, right| {
242        right
243            .score
244            .partial_cmp(&left.score)
245            .unwrap_or(Ordering::Equal)
246            // Among equally-relevant candidates, prefer coarser (more prefix-cache
247            // friendly) granularity first so the recalled block is stable across
248            // calls and does not churn the LLM prompt prefix (issue #61). Lower
249            // cache_stability_rank = coarser = earlier.
250            .then_with(|| {
251                TemporalGranularity::cache_stability_rank(left.granularity).cmp(
252                    &TemporalGranularity::cache_stability_rank(right.granularity),
253                )
254            })
255            .then_with(|| {
256                let left_dt = parse_rfc3339(&left.updated_at)
257                    .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC);
258                let right_dt = parse_rfc3339(&right.updated_at)
259                    .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC);
260                right_dt.cmp(&left_dt)
261            })
262            .then_with(|| left.title.cmp(&right.title))
263    });
264}
265
266fn build_rerank_prompt(query: &str, candidates: &[MemoryRecallCandidate], limit: usize) -> String {
267    let mut prompt = String::from("# Bamboo Relevant Memory Recall Rerank\n\n");
268    prompt.push_str(
269        "Select the durable memory candidates that are most relevant to the user query.\n",
270    );
271    prompt.push_str("Return JSON only in the form {\"ids\":[\"candidate-id\", ...]}.\n");
272    prompt
273        .push_str("Do not include commentary, markdown fences, explanations, or unknown ids.\n\n");
274    prompt.push_str("## User query\n");
275    prompt.push_str(query.trim());
276    prompt.push_str("\n\n## Candidate memories\n");
277
278    for (index, candidate) in candidates.iter().enumerate() {
279        prompt.push_str(&format!(
280            "{}. id={}\n   title: {}\n   scope: {}\n   status: {}\n   updated_at: {}\n   lexical_score: {:.2}\n   summary: {}\n",
281            index + 1,
282            candidate.id,
283            candidate.title,
284            candidate.scope.as_str(),
285            candidate.status.as_str(),
286            candidate.updated_at,
287            candidate.score,
288            candidate.summary.replace('\n', " "),
289        ));
290    }
291
292    prompt.push_str(&format!(
293        "\n## Selection rules\n- Return at most {limit} ids.\n- Use only ids from the candidate list above.\n- Prefer candidates that best answer the user query or encode active preferences/constraints relevant to it.\n- Prefer active memories over stale ones when relevance is otherwise similar.\n- Keep the ids ordered best-to-worst.\n"
294    ));
295    prompt
296}
297
298async fn rerank_candidate_ids(
299    query: &str,
300    candidates: &[MemoryRecallCandidate],
301    limit: usize,
302    context: &MemoryRecallRerankContext,
303) -> Result<Vec<String>, String> {
304    let model = context.model.trim();
305    if model.is_empty() {
306        return Err("rerank model is empty".to_string());
307    }
308
309    let messages = vec![
310        Message::system(
311            "You rerank Bamboo durable-memory recall candidates. Return strict JSON only in the form {\"ids\":[...]} using only candidate ids from the prompt.",
312        ),
313        Message::user(build_rerank_prompt(query, candidates, limit)),
314    ];
315    let options = LLMRequestOptions {
316        session_id: context.session_id.clone(),
317        reasoning_effort: Some(ReasoningEffort::High),
318        parallel_tool_calls: None,
319        required_tool: None,
320        responses: None,
321        request_purpose: Some("memory_rerank".to_string()),
322        cache: None,
323    };
324
325    let mut stream = context
326        .llm
327        .chat_stream_with_options(&messages, &[], Some(8192), model, Some(&options))
328        .await
329        .map_err(|error| format!("rerank provider call failed: {error}"))?;
330
331    let content = tokio::time::timeout(std::time::Duration::from_secs(30), async {
332        let mut content = String::new();
333        while let Some(chunk_result) = stream.next().await {
334            match chunk_result {
335                Ok(LLMChunk::Token(text)) => content.push_str(&text),
336                Ok(LLMChunk::Done) => break,
337                Ok(_) => {}
338                Err(error) => {
339                    if !content.trim().is_empty() {
340                        break;
341                    }
342                    return Err(format!("rerank stream failed: {error}"));
343                }
344            }
345        }
346        Ok(content)
347    })
348    .await
349    .unwrap_or_else(|_| Err("rerank timed out after 30s".to_string()))?;
350
351    parse_reranked_ids(&content, candidates)
352        .ok_or_else(|| format!("failed to parse rerank response: {}", content.trim()))
353}
354
355fn reorder_candidates_by_ids(
356    lexical_candidates: &[MemoryRecallCandidate],
357    preferred_ids: &[String],
358    limit: usize,
359) -> Vec<MemoryRecallCandidate> {
360    if lexical_candidates.is_empty() || limit == 0 {
361        return Vec::new();
362    }
363
364    let allowed = lexical_candidates
365        .iter()
366        .map(|candidate| candidate.id.as_str())
367        .collect::<HashSet<_>>();
368    let mut seen = HashSet::new();
369    let mut ordered = Vec::new();
370
371    for id in preferred_ids {
372        let trimmed = id.trim();
373        if trimmed.is_empty() || !allowed.contains(trimmed) || !seen.insert(trimmed.to_string()) {
374            continue;
375        }
376        if let Some(candidate) = lexical_candidates
377            .iter()
378            .find(|candidate| candidate.id == trimmed)
379            .cloned()
380        {
381            ordered.push(candidate);
382            if ordered.len() >= limit {
383                return ordered;
384            }
385        }
386    }
387
388    for candidate in lexical_candidates {
389        if seen.insert(candidate.id.clone()) {
390            ordered.push(candidate.clone());
391            if ordered.len() >= limit {
392                break;
393            }
394        }
395    }
396
397    ordered
398}
399
400fn parse_reranked_ids(raw: &str, candidates: &[MemoryRecallCandidate]) -> Option<Vec<String>> {
401    let stripped = strip_markdown_fence(raw);
402    let fragment = extract_json_fragment(&stripped).unwrap_or(stripped.trim());
403    let ids = serde_json::from_str::<MemoryRecallRerankEnvelope>(fragment)
404        .map(|value| value.ids)
405        .or_else(|_| serde_json::from_str::<Vec<String>>(fragment))
406        .ok()?;
407
408    let allowed = candidates
409        .iter()
410        .map(|candidate| candidate.id.as_str())
411        .collect::<HashSet<_>>();
412    let mut seen = HashSet::new();
413    let mut out = Vec::new();
414
415    for id in ids {
416        let trimmed = id.trim();
417        if trimmed.is_empty() || !allowed.contains(trimmed) || !seen.insert(trimmed.to_string()) {
418            continue;
419        }
420        out.push(trimmed.to_string());
421    }
422
423    // The response PARSED — return the (possibly empty) selection. `None` is
424    // reserved for genuinely unparseable output (handled above via `.ok()?`), so
425    // an empty `{"ids":[]}` — the reranker judging NONE relevant — is a valid
426    // result, not a parse failure. (Empty is acted on by the caller.)
427    Some(out)
428}
429
430fn strip_markdown_fence(raw: &str) -> String {
431    let trimmed = raw.trim();
432    for fence in ["````", "```"] {
433        if let Some(after_fence) = trimmed.strip_prefix(fence) {
434            let Some(first_newline) = after_fence.find('\n') else {
435                continue;
436            };
437            let body = &after_fence[first_newline + 1..];
438            if let Some(end_idx) = body.rfind(fence) {
439                return body[..end_idx].trim().to_string();
440            }
441        }
442    }
443    trimmed.to_string()
444}
445
446fn extract_json_fragment(raw: &str) -> Option<&str> {
447    let trimmed = raw.trim();
448    if trimmed.is_empty() {
449        return None;
450    }
451
452    if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) {
453        if start <= end {
454            return Some(trimmed[start..=end].trim());
455        }
456    }
457
458    if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']')) {
459        if start <= end {
460            return Some(trimmed[start..=end].trim());
461        }
462    }
463
464    None
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::memory_store::DurableMemoryType;
471    use async_trait::async_trait;
472    use bamboo_domain::ReasoningEffort;
473    use bamboo_llm::provider::LLMRequestOptions;
474    use bamboo_llm::{LLMChunk, LLMError, LLMProvider, LLMStream};
475    use futures::stream;
476    use std::sync::Mutex;
477    use tempfile::tempdir;
478
479    #[derive(Clone)]
480    struct StaticResponseProvider {
481        response: String,
482        requested_models: Arc<Mutex<Vec<String>>>,
483    }
484
485    impl StaticResponseProvider {
486        fn new(response: impl Into<String>) -> Self {
487            Self {
488                response: response.into(),
489                requested_models: Arc::new(Mutex::new(Vec::new())),
490            }
491        }
492    }
493
494    #[async_trait]
495    impl LLMProvider for StaticResponseProvider {
496        async fn chat_stream(
497            &self,
498            _messages: &[Message],
499            _tools: &[bamboo_agent_core::ToolSchema],
500            _max_output_tokens: Option<u32>,
501            model: &str,
502        ) -> Result<LLMStream, LLMError> {
503            self.requested_models
504                .lock()
505                .expect("lock poisoned")
506                .push(model.to_string());
507            Ok(Box::pin(stream::iter(vec![
508                Ok(LLMChunk::Token(self.response.clone())),
509                Ok(LLMChunk::Done),
510            ])))
511        }
512    }
513
514    fn candidate(
515        id: &str,
516        score: f64,
517        granularity: Option<TemporalGranularity>,
518    ) -> MemoryRecallCandidate {
519        MemoryRecallCandidate {
520            id: id.to_string(),
521            title: id.to_string(),
522            score,
523            scope: MemoryScope::Project,
524            project_key: Some("proj-1".to_string()),
525            status: DurableMemoryStatus::Active,
526            // Same timestamp for all so granularity is the deciding tie-breaker.
527            updated_at: "2026-04-09T00:00:00Z".to_string(),
528            summary: "summary".to_string(),
529            granularity,
530        }
531    }
532
533    #[test]
534    fn equal_score_candidates_sort_coarse_granularity_first_for_cache_stability() {
535        // All same score + same timestamp → granularity decides ordering. Coarser
536        // (year) is more prefix-cache friendly and must sort ahead of finer (day).
537        let mut candidates = vec![
538            candidate("day", 5.0, Some(TemporalGranularity::Day)),
539            candidate("year", 5.0, Some(TemporalGranularity::Year)),
540            candidate("none", 5.0, None),
541            candidate("month", 5.0, Some(TemporalGranularity::Month)),
542        ];
543        sort_recall_candidates(&mut candidates);
544        let order: Vec<&str> = candidates.iter().map(|c| c.id.as_str()).collect();
545        // None is treated as most stable (rank 0), then year, month, day.
546        assert_eq!(order, vec!["none", "year", "month", "day"]);
547    }
548
549    #[test]
550    fn higher_score_still_wins_over_cache_stable_granularity() {
551        // Granularity is only a tie-breaker: a more relevant (higher score) day-level
552        // memory must still outrank a less relevant year-level one.
553        let mut candidates = vec![
554            candidate("year-low", 1.0, Some(TemporalGranularity::Year)),
555            candidate("day-high", 9.0, Some(TemporalGranularity::Day)),
556        ];
557        sort_recall_candidates(&mut candidates);
558        assert_eq!(candidates[0].id, "day-high");
559    }
560
561    #[test]
562    fn parse_reranked_ids_accepts_fenced_json_and_filters_unknown_ids() {
563        let candidates = vec![
564            MemoryRecallCandidate {
565                id: "mem-a".to_string(),
566                title: "A".to_string(),
567                score: 10.0,
568                scope: MemoryScope::Project,
569                project_key: Some("proj-1".to_string()),
570                status: DurableMemoryStatus::Active,
571                updated_at: "2026-04-09T00:00:00Z".to_string(),
572                summary: "summary a".to_string(),
573                granularity: None,
574            },
575            MemoryRecallCandidate {
576                id: "mem-b".to_string(),
577                title: "B".to_string(),
578                score: 9.0,
579                scope: MemoryScope::Project,
580                project_key: Some("proj-1".to_string()),
581                status: DurableMemoryStatus::Active,
582                updated_at: "2026-04-09T00:00:00Z".to_string(),
583                summary: "summary b".to_string(),
584                granularity: None,
585            },
586        ];
587
588        let parsed = parse_reranked_ids(
589            "```json\n{\"ids\":[\"mem-b\",\"unknown\",\"mem-a\",\"mem-b\"]}\n```",
590            &candidates,
591        )
592        .expect("reranked ids should parse");
593
594        assert_eq!(parsed, vec!["mem-b".to_string(), "mem-a".to_string()]);
595    }
596
597    #[test]
598    fn reorder_candidates_by_ids_appends_remaining_lexical_candidates() {
599        let lexical = vec![
600            MemoryRecallCandidate {
601                id: "mem-a".to_string(),
602                title: "A".to_string(),
603                score: 10.0,
604                scope: MemoryScope::Project,
605                project_key: Some("proj-1".to_string()),
606                status: DurableMemoryStatus::Active,
607                updated_at: "2026-04-09T00:00:00Z".to_string(),
608                summary: "summary a".to_string(),
609                granularity: None,
610            },
611            MemoryRecallCandidate {
612                id: "mem-b".to_string(),
613                title: "B".to_string(),
614                score: 9.0,
615                scope: MemoryScope::Project,
616                project_key: Some("proj-1".to_string()),
617                status: DurableMemoryStatus::Active,
618                updated_at: "2026-04-09T00:00:00Z".to_string(),
619                summary: "summary b".to_string(),
620                granularity: None,
621            },
622            MemoryRecallCandidate {
623                id: "mem-c".to_string(),
624                title: "C".to_string(),
625                score: 8.0,
626                scope: MemoryScope::Project,
627                project_key: Some("proj-1".to_string()),
628                status: DurableMemoryStatus::Active,
629                updated_at: "2026-04-09T00:00:00Z".to_string(),
630                summary: "summary c".to_string(),
631                granularity: None,
632            },
633        ];
634
635        let reordered =
636            reorder_candidates_by_ids(&lexical, &["mem-c".to_string(), "mem-a".to_string()], 3);
637
638        assert_eq!(reordered[0].id, "mem-c");
639        assert_eq!(reordered[1].id, "mem-a");
640        assert_eq!(reordered[2].id, "mem-b");
641    }
642
643    #[tokio::test]
644    async fn project_scope_shortlist_excludes_global_when_project_hits_exist() {
645        let dir = tempdir().unwrap();
646        let store = MemoryStore::new(dir.path());
647
648        store
649            .write_memory(
650                MemoryScope::Project,
651                Some("proj-1"),
652                DurableMemoryType::Project,
653                "Release freeze decision",
654                "Project-specific release freeze note.",
655                &["release".to_string()],
656                Some("session-1"),
657                "main-model",
658                false,
659                None,
660            )
661            .await
662            .unwrap();
663        store
664            .write_memory(
665                MemoryScope::Global,
666                None,
667                DurableMemoryType::Reference,
668                "Global release guidance",
669                "Global note that should not be used when project hits exist.",
670                &["release".to_string()],
671                Some("session-1"),
672                "main-model",
673                false,
674                None,
675            )
676            .await
677            .unwrap();
678
679        let candidates = shortlist_relevant_memories(
680            &store,
681            Some("proj-1"),
682            "release freeze",
683            &MemoryRecallOptions::default(),
684        )
685        .await
686        .unwrap();
687
688        assert!(!candidates.is_empty());
689        assert!(candidates
690            .iter()
691            .all(|candidate| candidate.scope == MemoryScope::Project));
692    }
693
694    #[tokio::test]
695    async fn global_fallback_triggers_only_when_project_hits_are_absent() {
696        let dir = tempdir().unwrap();
697        let store = MemoryStore::new(dir.path());
698
699        store
700            .write_memory(
701                MemoryScope::Global,
702                None,
703                DurableMemoryType::Reference,
704                "Global release guidance",
705                "Fallback note for release work.",
706                &["release".to_string()],
707                Some("session-1"),
708                "main-model",
709                false,
710                None,
711            )
712            .await
713            .unwrap();
714
715        let candidates = shortlist_relevant_memories(
716            &store,
717            Some("proj-missing"),
718            "release guidance",
719            &MemoryRecallOptions::default(),
720        )
721        .await
722        .unwrap();
723
724        assert!(!candidates.is_empty());
725        assert!(candidates
726            .iter()
727            .all(|candidate| candidate.scope == MemoryScope::Global));
728    }
729
730    #[tokio::test]
731    async fn model_rerank_reorders_lexical_shortlist_when_enabled() {
732        let dir = tempdir().unwrap();
733        let store = MemoryStore::new(dir.path());
734
735        let lexical_first = store
736            .write_memory(
737                MemoryScope::Project,
738                Some("proj-1"),
739                DurableMemoryType::Project,
740                "Release freeze checklist",
741                "Generic release freeze checklist for shipping work.",
742                &["release".to_string(), "freeze".to_string()],
743                Some("session-1"),
744                "main-model",
745                false,
746                None,
747            )
748            .await
749            .unwrap();
750        let reranked_first = store
751            .write_memory(
752                MemoryScope::Project,
753                Some("proj-1"),
754                DurableMemoryType::Project,
755                "Mobile launch blocker",
756                "This durable note captures the release freeze decision for the mobile app and should be preferred for mobile freeze requests.",
757                &["mobile".to_string(), "launch".to_string()],
758                Some("session-1"),
759                "main-model",
760                false,
761                None,
762            )
763            .await
764            .unwrap();
765
766        let provider = StaticResponseProvider::new(format!(
767            "{{\"ids\":[\"{}\",\"{}\"]}}",
768            reranked_first.frontmatter.id, lexical_first.frontmatter.id
769        ));
770        let requested_models = provider.requested_models.clone();
771        let selection = select_relevant_memories(
772            &store,
773            Some("proj-1"),
774            "release freeze for mobile",
775            &MemoryRecallOptions {
776                shortlist_limit: 2,
777                include_global_fallback: false,
778                max_candidates_per_scope: 12,
779            },
780            Some(&MemoryRecallRerankContext {
781                llm: Arc::new(provider),
782                model: "rerank-fast-model".to_string(),
783                session_id: Some("session-1".to_string()),
784            }),
785        )
786        .await
787        .unwrap();
788
789        assert_eq!(selection.strategy, MemoryRecallStrategy::Reranked);
790        assert_eq!(selection.candidates.len(), 2);
791        assert_eq!(selection.candidates[0].id, reranked_first.frontmatter.id);
792        assert_eq!(selection.candidates[1].id, lexical_first.frontmatter.id);
793        assert_eq!(
794            requested_models.lock().expect("lock poisoned").as_slice(),
795            ["rerank-fast-model"]
796        );
797    }
798
799    #[tokio::test]
800    async fn invalid_model_rerank_response_falls_back_to_lexical_order() {
801        let dir = tempdir().unwrap();
802        let store = MemoryStore::new(dir.path());
803
804        let lexical_first = store
805            .write_memory(
806                MemoryScope::Project,
807                Some("proj-1"),
808                DurableMemoryType::Project,
809                "Release freeze checklist",
810                "Generic release freeze checklist for shipping work.",
811                &["release".to_string(), "freeze".to_string()],
812                Some("session-1"),
813                "main-model",
814                false,
815                None,
816            )
817            .await
818            .unwrap();
819        let lexical_second = store
820            .write_memory(
821                MemoryScope::Project,
822                Some("proj-1"),
823                DurableMemoryType::Project,
824                "Mobile launch blocker",
825                "This durable note captures the release freeze decision for the mobile app.",
826                &["mobile".to_string(), "launch".to_string()],
827                Some("session-1"),
828                "main-model",
829                false,
830                None,
831            )
832            .await
833            .unwrap();
834
835        let selection = select_relevant_memories(
836            &store,
837            Some("proj-1"),
838            "release freeze for mobile",
839            &MemoryRecallOptions {
840                shortlist_limit: 2,
841                include_global_fallback: false,
842                max_candidates_per_scope: 12,
843            },
844            Some(&MemoryRecallRerankContext {
845                llm: Arc::new(StaticResponseProvider::new("not valid json")),
846                model: "rerank-fast-model".to_string(),
847                session_id: Some("session-1".to_string()),
848            }),
849        )
850        .await
851        .unwrap();
852
853        assert_eq!(selection.strategy, MemoryRecallStrategy::RerankFallback);
854        assert_eq!(selection.candidates.len(), 2);
855        // The fallback contract is "return the lexical shortlist in its own order",
856        // NOT any hard-coded doc order — verify it matches the pure lexical shortlist
857        // (which BM25 orders by relevance) and preserves both durable memories.
858        let lexical = shortlist_relevant_memories(
859            &store,
860            Some("proj-1"),
861            "release freeze for mobile",
862            &MemoryRecallOptions {
863                shortlist_limit: 2,
864                include_global_fallback: false,
865                max_candidates_per_scope: 12,
866            },
867        )
868        .await
869        .unwrap();
870        let fallback_ids: Vec<&str> = selection.candidates.iter().map(|c| c.id.as_str()).collect();
871        let lexical_ids: Vec<&str> = lexical.iter().map(|c| c.id.as_str()).collect();
872        assert_eq!(
873            fallback_ids, lexical_ids,
874            "fallback must preserve lexical order"
875        );
876        assert!(fallback_ids.contains(&lexical_first.frontmatter.id.as_str()));
877        assert!(fallback_ids.contains(&lexical_second.frontmatter.id.as_str()));
878    }
879
880    #[tokio::test]
881    async fn empty_rerank_selection_returns_no_memories_not_lexical() {
882        let dir = tempdir().unwrap();
883        let store = MemoryStore::new(dir.path());
884
885        // Two lexically-matched candidates the reranker will reject.
886        store
887            .write_memory(
888                MemoryScope::Project,
889                Some("proj-1"),
890                DurableMemoryType::Project,
891                "Release freeze checklist",
892                "Generic release freeze checklist for shipping work.",
893                &["release".to_string(), "freeze".to_string()],
894                Some("session-1"),
895                "main-model",
896                false,
897                None,
898            )
899            .await
900            .unwrap();
901        store
902            .write_memory(
903                MemoryScope::Project,
904                Some("proj-1"),
905                DurableMemoryType::Project,
906                "Mobile launch blocker",
907                "This durable note captures the release freeze decision for the mobile app.",
908                &["mobile".to_string(), "launch".to_string()],
909                Some("session-1"),
910                "main-model",
911                false,
912                None,
913            )
914            .await
915            .unwrap();
916
917        // The reranker judges NONE of the shortlist relevant → `{"ids":[]}`.
918        let selection = select_relevant_memories(
919            &store,
920            Some("proj-1"),
921            "release freeze for mobile",
922            &MemoryRecallOptions {
923                shortlist_limit: 2,
924                include_global_fallback: false,
925                max_candidates_per_scope: 12,
926            },
927            Some(&MemoryRecallRerankContext {
928                llm: Arc::new(StaticResponseProvider::new("{\"ids\":[]}")),
929                model: "rerank-fast-model".to_string(),
930                session_id: Some("session-1".to_string()),
931            }),
932        )
933        .await
934        .unwrap();
935
936        // Respect the reranker: no memories surfaced, and it is NOT treated as a
937        // failure/fallback (the pre-fix bug misreported `{"ids":[]}` as a parse
938        // error → lexical fallback → surfaced the rejected memories).
939        assert_eq!(selection.strategy, MemoryRecallStrategy::Reranked);
940        assert!(
941            selection.candidates.is_empty(),
942            "an empty rerank selection must surface no memories, got {}",
943            selection.candidates.len()
944        );
945    }
946
947    /// Provider that captures `max_output_tokens` and `reasoning_effort` from `chat_stream_with_options`.
948    #[derive(Default)]
949    struct RequestOptionsCaptureProvider {
950        captured_max_tokens: Mutex<Vec<Option<u32>>>,
951        captured_reasoning: Mutex<Vec<Option<ReasoningEffort>>>,
952    }
953
954    #[async_trait]
955    impl LLMProvider for RequestOptionsCaptureProvider {
956        async fn chat_stream(
957            &self,
958            _messages: &[Message],
959            _tools: &[bamboo_agent_core::ToolSchema],
960            _max_output_tokens: Option<u32>,
961            _model: &str,
962        ) -> Result<LLMStream, LLMError> {
963            Ok(Box::pin(stream::iter(vec![
964                Ok(LLMChunk::Token("{\"ids\":[]}".to_string())),
965                Ok(LLMChunk::Done),
966            ])))
967        }
968
969        async fn chat_stream_with_options(
970            &self,
971            messages: &[Message],
972            tools: &[bamboo_agent_core::ToolSchema],
973            max_output_tokens: Option<u32>,
974            model: &str,
975            options: Option<&LLMRequestOptions>,
976        ) -> Result<LLMStream, LLMError> {
977            self.captured_max_tokens
978                .lock()
979                .expect("lock should not be poisoned")
980                .push(max_output_tokens);
981            self.captured_reasoning
982                .lock()
983                .expect("lock should not be poisoned")
984                .push(options.and_then(|o| o.reasoning_effort));
985            self.chat_stream(messages, tools, max_output_tokens, model)
986                .await
987        }
988    }
989
990    #[tokio::test]
991    async fn rerank_sufficient_max_tokens_for_high_reasoning() {
992        let provider = Arc::new(RequestOptionsCaptureProvider::default());
993        let candidates = vec![MemoryRecallCandidate {
994            id: "mem-1".to_string(),
995            score: 0.9,
996            title: "Test memory".to_string(),
997            scope: MemoryScope::Project,
998            project_key: Some("proj-1".to_string()),
999            status: DurableMemoryStatus::Active,
1000            updated_at: "2026-05-08T00:00:00Z".to_string(),
1001            summary: "A test durable memory entry".to_string(),
1002            granularity: None,
1003        }];
1004        let context = MemoryRecallRerankContext {
1005            llm: provider.clone(),
1006            model: "deepseek-v4-pro".to_string(),
1007            session_id: Some("test-session".to_string()),
1008        };
1009
1010        let _ = rerank_candidate_ids("test query", &candidates, 5, &context).await;
1011
1012        let captured_reasoning = provider
1013            .captured_reasoning
1014            .lock()
1015            .expect("lock should not be poisoned");
1016        let captured_max_tokens = provider
1017            .captured_max_tokens
1018            .lock()
1019            .expect("lock should not be poisoned");
1020        assert_eq!(
1021            captured_reasoning.as_slice(),
1022            [Some(ReasoningEffort::High)],
1023            "rerank should request High reasoning"
1024        );
1025        let max_tokens = captured_max_tokens[0].expect("max_output_tokens should be set");
1026        assert!(
1027            max_tokens > 4096,
1028            "max_output_tokens ({}) must exceed thinking budget (4096) to avoid truncation",
1029            max_tokens
1030        );
1031    }
1032}