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