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        responses: None,
320        request_purpose: Some("memory_rerank".to_string()),
321        cache: None,
322    };
323
324    let mut stream = context
325        .llm
326        .chat_stream_with_options(&messages, &[], Some(8192), model, Some(&options))
327        .await
328        .map_err(|error| format!("rerank provider call failed: {error}"))?;
329
330    let content = tokio::time::timeout(std::time::Duration::from_secs(30), async {
331        let mut content = String::new();
332        while let Some(chunk_result) = stream.next().await {
333            match chunk_result {
334                Ok(LLMChunk::Token(text)) => content.push_str(&text),
335                Ok(LLMChunk::Done) => break,
336                Ok(_) => {}
337                Err(error) => {
338                    if !content.trim().is_empty() {
339                        break;
340                    }
341                    return Err(format!("rerank stream failed: {error}"));
342                }
343            }
344        }
345        Ok(content)
346    })
347    .await
348    .unwrap_or_else(|_| Err("rerank timed out after 30s".to_string()))?;
349
350    parse_reranked_ids(&content, candidates)
351        .ok_or_else(|| format!("failed to parse rerank response: {}", content.trim()))
352}
353
354fn reorder_candidates_by_ids(
355    lexical_candidates: &[MemoryRecallCandidate],
356    preferred_ids: &[String],
357    limit: usize,
358) -> Vec<MemoryRecallCandidate> {
359    if lexical_candidates.is_empty() || limit == 0 {
360        return Vec::new();
361    }
362
363    let allowed = lexical_candidates
364        .iter()
365        .map(|candidate| candidate.id.as_str())
366        .collect::<HashSet<_>>();
367    let mut seen = HashSet::new();
368    let mut ordered = Vec::new();
369
370    for id in preferred_ids {
371        let trimmed = id.trim();
372        if trimmed.is_empty() || !allowed.contains(trimmed) || !seen.insert(trimmed.to_string()) {
373            continue;
374        }
375        if let Some(candidate) = lexical_candidates
376            .iter()
377            .find(|candidate| candidate.id == trimmed)
378            .cloned()
379        {
380            ordered.push(candidate);
381            if ordered.len() >= limit {
382                return ordered;
383            }
384        }
385    }
386
387    for candidate in lexical_candidates {
388        if seen.insert(candidate.id.clone()) {
389            ordered.push(candidate.clone());
390            if ordered.len() >= limit {
391                break;
392            }
393        }
394    }
395
396    ordered
397}
398
399fn parse_reranked_ids(raw: &str, candidates: &[MemoryRecallCandidate]) -> Option<Vec<String>> {
400    let stripped = strip_markdown_fence(raw);
401    let fragment = extract_json_fragment(&stripped).unwrap_or(stripped.trim());
402    let ids = serde_json::from_str::<MemoryRecallRerankEnvelope>(fragment)
403        .map(|value| value.ids)
404        .or_else(|_| serde_json::from_str::<Vec<String>>(fragment))
405        .ok()?;
406
407    let allowed = candidates
408        .iter()
409        .map(|candidate| candidate.id.as_str())
410        .collect::<HashSet<_>>();
411    let mut seen = HashSet::new();
412    let mut out = Vec::new();
413
414    for id in ids {
415        let trimmed = id.trim();
416        if trimmed.is_empty() || !allowed.contains(trimmed) || !seen.insert(trimmed.to_string()) {
417            continue;
418        }
419        out.push(trimmed.to_string());
420    }
421
422    // The response PARSED — return the (possibly empty) selection. `None` is
423    // reserved for genuinely unparseable output (handled above via `.ok()?`), so
424    // an empty `{"ids":[]}` — the reranker judging NONE relevant — is a valid
425    // result, not a parse failure. (Empty is acted on by the caller.)
426    Some(out)
427}
428
429fn strip_markdown_fence(raw: &str) -> String {
430    let trimmed = raw.trim();
431    for fence in ["````", "```"] {
432        if let Some(after_fence) = trimmed.strip_prefix(fence) {
433            let Some(first_newline) = after_fence.find('\n') else {
434                continue;
435            };
436            let body = &after_fence[first_newline + 1..];
437            if let Some(end_idx) = body.rfind(fence) {
438                return body[..end_idx].trim().to_string();
439            }
440        }
441    }
442    trimmed.to_string()
443}
444
445fn extract_json_fragment(raw: &str) -> Option<&str> {
446    let trimmed = raw.trim();
447    if trimmed.is_empty() {
448        return None;
449    }
450
451    if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) {
452        if start <= end {
453            return Some(trimmed[start..=end].trim());
454        }
455    }
456
457    if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']')) {
458        if start <= end {
459            return Some(trimmed[start..=end].trim());
460        }
461    }
462
463    None
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::memory_store::DurableMemoryType;
470    use async_trait::async_trait;
471    use bamboo_domain::ReasoningEffort;
472    use bamboo_llm::provider::LLMRequestOptions;
473    use bamboo_llm::{LLMChunk, LLMError, LLMProvider, LLMStream};
474    use futures::stream;
475    use std::sync::Mutex;
476    use tempfile::tempdir;
477
478    #[derive(Clone)]
479    struct StaticResponseProvider {
480        response: String,
481        requested_models: Arc<Mutex<Vec<String>>>,
482    }
483
484    impl StaticResponseProvider {
485        fn new(response: impl Into<String>) -> Self {
486            Self {
487                response: response.into(),
488                requested_models: Arc::new(Mutex::new(Vec::new())),
489            }
490        }
491    }
492
493    #[async_trait]
494    impl LLMProvider for StaticResponseProvider {
495        async fn chat_stream(
496            &self,
497            _messages: &[Message],
498            _tools: &[bamboo_agent_core::ToolSchema],
499            _max_output_tokens: Option<u32>,
500            model: &str,
501        ) -> Result<LLMStream, LLMError> {
502            self.requested_models
503                .lock()
504                .expect("lock poisoned")
505                .push(model.to_string());
506            Ok(Box::pin(stream::iter(vec![
507                Ok(LLMChunk::Token(self.response.clone())),
508                Ok(LLMChunk::Done),
509            ])))
510        }
511    }
512
513    fn candidate(
514        id: &str,
515        score: f64,
516        granularity: Option<TemporalGranularity>,
517    ) -> MemoryRecallCandidate {
518        MemoryRecallCandidate {
519            id: id.to_string(),
520            title: id.to_string(),
521            score,
522            scope: MemoryScope::Project,
523            project_key: Some("proj-1".to_string()),
524            status: DurableMemoryStatus::Active,
525            // Same timestamp for all so granularity is the deciding tie-breaker.
526            updated_at: "2026-04-09T00:00:00Z".to_string(),
527            summary: "summary".to_string(),
528            granularity,
529        }
530    }
531
532    #[test]
533    fn equal_score_candidates_sort_coarse_granularity_first_for_cache_stability() {
534        // All same score + same timestamp → granularity decides ordering. Coarser
535        // (year) is more prefix-cache friendly and must sort ahead of finer (day).
536        let mut candidates = vec![
537            candidate("day", 5.0, Some(TemporalGranularity::Day)),
538            candidate("year", 5.0, Some(TemporalGranularity::Year)),
539            candidate("none", 5.0, None),
540            candidate("month", 5.0, Some(TemporalGranularity::Month)),
541        ];
542        sort_recall_candidates(&mut candidates);
543        let order: Vec<&str> = candidates.iter().map(|c| c.id.as_str()).collect();
544        // None is treated as most stable (rank 0), then year, month, day.
545        assert_eq!(order, vec!["none", "year", "month", "day"]);
546    }
547
548    #[test]
549    fn higher_score_still_wins_over_cache_stable_granularity() {
550        // Granularity is only a tie-breaker: a more relevant (higher score) day-level
551        // memory must still outrank a less relevant year-level one.
552        let mut candidates = vec![
553            candidate("year-low", 1.0, Some(TemporalGranularity::Year)),
554            candidate("day-high", 9.0, Some(TemporalGranularity::Day)),
555        ];
556        sort_recall_candidates(&mut candidates);
557        assert_eq!(candidates[0].id, "day-high");
558    }
559
560    #[test]
561    fn parse_reranked_ids_accepts_fenced_json_and_filters_unknown_ids() {
562        let candidates = vec![
563            MemoryRecallCandidate {
564                id: "mem-a".to_string(),
565                title: "A".to_string(),
566                score: 10.0,
567                scope: MemoryScope::Project,
568                project_key: Some("proj-1".to_string()),
569                status: DurableMemoryStatus::Active,
570                updated_at: "2026-04-09T00:00:00Z".to_string(),
571                summary: "summary a".to_string(),
572                granularity: None,
573            },
574            MemoryRecallCandidate {
575                id: "mem-b".to_string(),
576                title: "B".to_string(),
577                score: 9.0,
578                scope: MemoryScope::Project,
579                project_key: Some("proj-1".to_string()),
580                status: DurableMemoryStatus::Active,
581                updated_at: "2026-04-09T00:00:00Z".to_string(),
582                summary: "summary b".to_string(),
583                granularity: None,
584            },
585        ];
586
587        let parsed = parse_reranked_ids(
588            "```json\n{\"ids\":[\"mem-b\",\"unknown\",\"mem-a\",\"mem-b\"]}\n```",
589            &candidates,
590        )
591        .expect("reranked ids should parse");
592
593        assert_eq!(parsed, vec!["mem-b".to_string(), "mem-a".to_string()]);
594    }
595
596    #[test]
597    fn reorder_candidates_by_ids_appends_remaining_lexical_candidates() {
598        let lexical = vec![
599            MemoryRecallCandidate {
600                id: "mem-a".to_string(),
601                title: "A".to_string(),
602                score: 10.0,
603                scope: MemoryScope::Project,
604                project_key: Some("proj-1".to_string()),
605                status: DurableMemoryStatus::Active,
606                updated_at: "2026-04-09T00:00:00Z".to_string(),
607                summary: "summary a".to_string(),
608                granularity: None,
609            },
610            MemoryRecallCandidate {
611                id: "mem-b".to_string(),
612                title: "B".to_string(),
613                score: 9.0,
614                scope: MemoryScope::Project,
615                project_key: Some("proj-1".to_string()),
616                status: DurableMemoryStatus::Active,
617                updated_at: "2026-04-09T00:00:00Z".to_string(),
618                summary: "summary b".to_string(),
619                granularity: None,
620            },
621            MemoryRecallCandidate {
622                id: "mem-c".to_string(),
623                title: "C".to_string(),
624                score: 8.0,
625                scope: MemoryScope::Project,
626                project_key: Some("proj-1".to_string()),
627                status: DurableMemoryStatus::Active,
628                updated_at: "2026-04-09T00:00:00Z".to_string(),
629                summary: "summary c".to_string(),
630                granularity: None,
631            },
632        ];
633
634        let reordered =
635            reorder_candidates_by_ids(&lexical, &["mem-c".to_string(), "mem-a".to_string()], 3);
636
637        assert_eq!(reordered[0].id, "mem-c");
638        assert_eq!(reordered[1].id, "mem-a");
639        assert_eq!(reordered[2].id, "mem-b");
640    }
641
642    #[tokio::test]
643    async fn project_scope_shortlist_excludes_global_when_project_hits_exist() {
644        let dir = tempdir().unwrap();
645        let store = MemoryStore::new(dir.path());
646
647        store
648            .write_memory(
649                MemoryScope::Project,
650                Some("proj-1"),
651                DurableMemoryType::Project,
652                "Release freeze decision",
653                "Project-specific release freeze note.",
654                &["release".to_string()],
655                Some("session-1"),
656                "main-model",
657                false,
658                None,
659            )
660            .await
661            .unwrap();
662        store
663            .write_memory(
664                MemoryScope::Global,
665                None,
666                DurableMemoryType::Reference,
667                "Global release guidance",
668                "Global note that should not be used when project hits exist.",
669                &["release".to_string()],
670                Some("session-1"),
671                "main-model",
672                false,
673                None,
674            )
675            .await
676            .unwrap();
677
678        let candidates = shortlist_relevant_memories(
679            &store,
680            Some("proj-1"),
681            "release freeze",
682            &MemoryRecallOptions::default(),
683        )
684        .await
685        .unwrap();
686
687        assert!(!candidates.is_empty());
688        assert!(candidates
689            .iter()
690            .all(|candidate| candidate.scope == MemoryScope::Project));
691    }
692
693    #[tokio::test]
694    async fn global_fallback_triggers_only_when_project_hits_are_absent() {
695        let dir = tempdir().unwrap();
696        let store = MemoryStore::new(dir.path());
697
698        store
699            .write_memory(
700                MemoryScope::Global,
701                None,
702                DurableMemoryType::Reference,
703                "Global release guidance",
704                "Fallback note for release work.",
705                &["release".to_string()],
706                Some("session-1"),
707                "main-model",
708                false,
709                None,
710            )
711            .await
712            .unwrap();
713
714        let candidates = shortlist_relevant_memories(
715            &store,
716            Some("proj-missing"),
717            "release guidance",
718            &MemoryRecallOptions::default(),
719        )
720        .await
721        .unwrap();
722
723        assert!(!candidates.is_empty());
724        assert!(candidates
725            .iter()
726            .all(|candidate| candidate.scope == MemoryScope::Global));
727    }
728
729    #[tokio::test]
730    async fn model_rerank_reorders_lexical_shortlist_when_enabled() {
731        let dir = tempdir().unwrap();
732        let store = MemoryStore::new(dir.path());
733
734        let lexical_first = store
735            .write_memory(
736                MemoryScope::Project,
737                Some("proj-1"),
738                DurableMemoryType::Project,
739                "Release freeze checklist",
740                "Generic release freeze checklist for shipping work.",
741                &["release".to_string(), "freeze".to_string()],
742                Some("session-1"),
743                "main-model",
744                false,
745                None,
746            )
747            .await
748            .unwrap();
749        let reranked_first = store
750            .write_memory(
751                MemoryScope::Project,
752                Some("proj-1"),
753                DurableMemoryType::Project,
754                "Mobile launch blocker",
755                "This durable note captures the release freeze decision for the mobile app and should be preferred for mobile freeze requests.",
756                &["mobile".to_string(), "launch".to_string()],
757                Some("session-1"),
758                "main-model",
759                false,
760                None,
761            )
762            .await
763            .unwrap();
764
765        let provider = StaticResponseProvider::new(format!(
766            "{{\"ids\":[\"{}\",\"{}\"]}}",
767            reranked_first.frontmatter.id, lexical_first.frontmatter.id
768        ));
769        let requested_models = provider.requested_models.clone();
770        let selection = select_relevant_memories(
771            &store,
772            Some("proj-1"),
773            "release freeze for mobile",
774            &MemoryRecallOptions {
775                shortlist_limit: 2,
776                include_global_fallback: false,
777                max_candidates_per_scope: 12,
778            },
779            Some(&MemoryRecallRerankContext {
780                llm: Arc::new(provider),
781                model: "rerank-fast-model".to_string(),
782                session_id: Some("session-1".to_string()),
783            }),
784        )
785        .await
786        .unwrap();
787
788        assert_eq!(selection.strategy, MemoryRecallStrategy::Reranked);
789        assert_eq!(selection.candidates.len(), 2);
790        assert_eq!(selection.candidates[0].id, reranked_first.frontmatter.id);
791        assert_eq!(selection.candidates[1].id, lexical_first.frontmatter.id);
792        assert_eq!(
793            requested_models.lock().expect("lock poisoned").as_slice(),
794            ["rerank-fast-model"]
795        );
796    }
797
798    #[tokio::test]
799    async fn invalid_model_rerank_response_falls_back_to_lexical_order() {
800        let dir = tempdir().unwrap();
801        let store = MemoryStore::new(dir.path());
802
803        let lexical_first = store
804            .write_memory(
805                MemoryScope::Project,
806                Some("proj-1"),
807                DurableMemoryType::Project,
808                "Release freeze checklist",
809                "Generic release freeze checklist for shipping work.",
810                &["release".to_string(), "freeze".to_string()],
811                Some("session-1"),
812                "main-model",
813                false,
814                None,
815            )
816            .await
817            .unwrap();
818        let lexical_second = store
819            .write_memory(
820                MemoryScope::Project,
821                Some("proj-1"),
822                DurableMemoryType::Project,
823                "Mobile launch blocker",
824                "This durable note captures the release freeze decision for the mobile app.",
825                &["mobile".to_string(), "launch".to_string()],
826                Some("session-1"),
827                "main-model",
828                false,
829                None,
830            )
831            .await
832            .unwrap();
833
834        let selection = select_relevant_memories(
835            &store,
836            Some("proj-1"),
837            "release freeze for mobile",
838            &MemoryRecallOptions {
839                shortlist_limit: 2,
840                include_global_fallback: false,
841                max_candidates_per_scope: 12,
842            },
843            Some(&MemoryRecallRerankContext {
844                llm: Arc::new(StaticResponseProvider::new("not valid json")),
845                model: "rerank-fast-model".to_string(),
846                session_id: Some("session-1".to_string()),
847            }),
848        )
849        .await
850        .unwrap();
851
852        assert_eq!(selection.strategy, MemoryRecallStrategy::RerankFallback);
853        assert_eq!(selection.candidates.len(), 2);
854        // The fallback contract is "return the lexical shortlist in its own order",
855        // NOT any hard-coded doc order — verify it matches the pure lexical shortlist
856        // (which BM25 orders by relevance) and preserves both durable memories.
857        let lexical = shortlist_relevant_memories(
858            &store,
859            Some("proj-1"),
860            "release freeze for mobile",
861            &MemoryRecallOptions {
862                shortlist_limit: 2,
863                include_global_fallback: false,
864                max_candidates_per_scope: 12,
865            },
866        )
867        .await
868        .unwrap();
869        let fallback_ids: Vec<&str> = selection.candidates.iter().map(|c| c.id.as_str()).collect();
870        let lexical_ids: Vec<&str> = lexical.iter().map(|c| c.id.as_str()).collect();
871        assert_eq!(
872            fallback_ids, lexical_ids,
873            "fallback must preserve lexical order"
874        );
875        assert!(fallback_ids.contains(&lexical_first.frontmatter.id.as_str()));
876        assert!(fallback_ids.contains(&lexical_second.frontmatter.id.as_str()));
877    }
878
879    #[tokio::test]
880    async fn empty_rerank_selection_returns_no_memories_not_lexical() {
881        let dir = tempdir().unwrap();
882        let store = MemoryStore::new(dir.path());
883
884        // Two lexically-matched candidates the reranker will reject.
885        store
886            .write_memory(
887                MemoryScope::Project,
888                Some("proj-1"),
889                DurableMemoryType::Project,
890                "Release freeze checklist",
891                "Generic release freeze checklist for shipping work.",
892                &["release".to_string(), "freeze".to_string()],
893                Some("session-1"),
894                "main-model",
895                false,
896                None,
897            )
898            .await
899            .unwrap();
900        store
901            .write_memory(
902                MemoryScope::Project,
903                Some("proj-1"),
904                DurableMemoryType::Project,
905                "Mobile launch blocker",
906                "This durable note captures the release freeze decision for the mobile app.",
907                &["mobile".to_string(), "launch".to_string()],
908                Some("session-1"),
909                "main-model",
910                false,
911                None,
912            )
913            .await
914            .unwrap();
915
916        // The reranker judges NONE of the shortlist relevant → `{"ids":[]}`.
917        let selection = select_relevant_memories(
918            &store,
919            Some("proj-1"),
920            "release freeze for mobile",
921            &MemoryRecallOptions {
922                shortlist_limit: 2,
923                include_global_fallback: false,
924                max_candidates_per_scope: 12,
925            },
926            Some(&MemoryRecallRerankContext {
927                llm: Arc::new(StaticResponseProvider::new("{\"ids\":[]}")),
928                model: "rerank-fast-model".to_string(),
929                session_id: Some("session-1".to_string()),
930            }),
931        )
932        .await
933        .unwrap();
934
935        // Respect the reranker: no memories surfaced, and it is NOT treated as a
936        // failure/fallback (the pre-fix bug misreported `{"ids":[]}` as a parse
937        // error → lexical fallback → surfaced the rejected memories).
938        assert_eq!(selection.strategy, MemoryRecallStrategy::Reranked);
939        assert!(
940            selection.candidates.is_empty(),
941            "an empty rerank selection must surface no memories, got {}",
942            selection.candidates.len()
943        );
944    }
945
946    /// Provider that captures `max_output_tokens` and `reasoning_effort` from `chat_stream_with_options`.
947    #[derive(Default)]
948    struct RequestOptionsCaptureProvider {
949        captured_max_tokens: Mutex<Vec<Option<u32>>>,
950        captured_reasoning: Mutex<Vec<Option<ReasoningEffort>>>,
951    }
952
953    #[async_trait]
954    impl LLMProvider for RequestOptionsCaptureProvider {
955        async fn chat_stream(
956            &self,
957            _messages: &[Message],
958            _tools: &[bamboo_agent_core::ToolSchema],
959            _max_output_tokens: Option<u32>,
960            _model: &str,
961        ) -> Result<LLMStream, LLMError> {
962            Ok(Box::pin(stream::iter(vec![
963                Ok(LLMChunk::Token("{\"ids\":[]}".to_string())),
964                Ok(LLMChunk::Done),
965            ])))
966        }
967
968        async fn chat_stream_with_options(
969            &self,
970            messages: &[Message],
971            tools: &[bamboo_agent_core::ToolSchema],
972            max_output_tokens: Option<u32>,
973            model: &str,
974            options: Option<&LLMRequestOptions>,
975        ) -> Result<LLMStream, LLMError> {
976            self.captured_max_tokens
977                .lock()
978                .expect("lock should not be poisoned")
979                .push(max_output_tokens);
980            self.captured_reasoning
981                .lock()
982                .expect("lock should not be poisoned")
983                .push(options.and_then(|o| o.reasoning_effort));
984            self.chat_stream(messages, tools, max_output_tokens, model)
985                .await
986        }
987    }
988
989    #[tokio::test]
990    async fn rerank_sufficient_max_tokens_for_high_reasoning() {
991        let provider = Arc::new(RequestOptionsCaptureProvider::default());
992        let candidates = vec![MemoryRecallCandidate {
993            id: "mem-1".to_string(),
994            score: 0.9,
995            title: "Test memory".to_string(),
996            scope: MemoryScope::Project,
997            project_key: Some("proj-1".to_string()),
998            status: DurableMemoryStatus::Active,
999            updated_at: "2026-05-08T00:00:00Z".to_string(),
1000            summary: "A test durable memory entry".to_string(),
1001            granularity: None,
1002        }];
1003        let context = MemoryRecallRerankContext {
1004            llm: provider.clone(),
1005            model: "deepseek-v4-pro".to_string(),
1006            session_id: Some("test-session".to_string()),
1007        };
1008
1009        let _ = rerank_candidate_ids("test query", &candidates, 5, &context).await;
1010
1011        let captured_reasoning = provider
1012            .captured_reasoning
1013            .lock()
1014            .expect("lock should not be poisoned");
1015        let captured_max_tokens = provider
1016            .captured_max_tokens
1017            .lock()
1018            .expect("lock should not be poisoned");
1019        assert_eq!(
1020            captured_reasoning.as_slice(),
1021            [Some(ReasoningEffort::High)],
1022            "rerank should request High reasoning"
1023        );
1024        let max_tokens = captured_max_tokens[0].expect("max_output_tokens should be set");
1025        assert!(
1026            max_tokens > 4096,
1027            "max_output_tokens ({}) must exceed thinking budget (4096) to avoid truncation",
1028            max_tokens
1029        );
1030    }
1031}