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 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 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 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 .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 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 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 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 assert_eq!(order, vec!["none", "year", "month", "day"]);
547 }
548
549 #[test]
550 fn higher_score_still_wins_over_cache_stable_granularity() {
551 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 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 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 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 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 #[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}