1use std::future::Future;
17use std::pin::Pin;
18
19use futures::StreamExt as _;
20use futures::stream::FuturesUnordered;
21
22use zeph_common::memory::{
23 AsyncMemoryRouter, CompressionLevel, FunctionalType, GraphRecallParams, TokenCounting,
24};
25use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
26
27use crate::budget::BudgetAllocation;
28use crate::error::AssemblerError;
29use crate::input::ContextAssemblyInput;
30use crate::slot::ContextSlot;
31
32pub(crate) fn levels_to_flags(levels: &[CompressionLevel]) -> (bool, bool, bool) {
40 if levels.is_empty() {
41 return (true, true, true);
42 }
43 let episodic = levels.contains(&CompressionLevel::Episodic);
44 let procedural = levels.contains(&CompressionLevel::Procedural);
45 let declarative = levels.contains(&CompressionLevel::Declarative);
46 (episodic, procedural, declarative)
47}
48
49pub(crate) fn type_active(active: &[FunctionalType], t: FunctionalType) -> bool {
58 active.is_empty() || active.contains(&t)
59}
60
61pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n";
63pub const CROSS_SESSION_PREFIX: &str = "[cross-session context]\n";
65pub const RECALL_PREFIX: &str = "[semantic recall]\n";
67pub const CORRECTIONS_PREFIX: &str = "[past corrections]\n";
69pub const DOCUMENT_RAG_PREFIX: &str = "## Relevant documents\n";
71pub const GRAPH_FACTS_PREFIX: &str = "[known facts]\n";
73
74const MEMORY_FETCH_TIMEOUT_MS: u64 = 1000;
82
83#[derive(Default)]
88pub struct PreparedContext {
89 pub graph_facts: Option<Message>,
91 pub doc_rag: Option<Message>,
93 pub corrections: Option<Message>,
95 pub recall: Option<Message>,
97 pub recall_confidence: Option<f32>,
99 pub cross_session: Option<Message>,
101 pub summaries: Option<Message>,
103 pub code_context: Option<String>,
105 pub persona_facts: Option<Message>,
107 pub trajectory_hints: Option<Message>,
109 pub tree_memory: Option<Message>,
111 pub reasoning_hints: Option<Message>,
113 pub memory_first: bool,
115 pub recent_history_budget: usize,
117 pub background_tasks: Vec<tokio::task::JoinHandle<()>>,
122}
123
124pub struct ContextAssembler;
128
129type CtxFuture<'a> = Pin<Box<dyn Future<Output = Result<ContextSlot, AssemblerError>> + Send + 'a>>;
130
131fn empty_prepared_context() -> PreparedContext {
132 PreparedContext::default()
133}
134
135fn resolve_effective_strategy(
136 memory: &crate::input::ContextMemoryView,
137 sidequest_turn_counter: u64,
138) -> zeph_config::ContextStrategy {
139 match memory.context_strategy {
140 zeph_config::ContextStrategy::MemoryFirst => zeph_config::ContextStrategy::MemoryFirst,
141 zeph_config::ContextStrategy::Adaptive => {
142 if sidequest_turn_counter >= u64::from(memory.crossover_turn_threshold) {
143 zeph_config::ContextStrategy::MemoryFirst
144 } else {
145 zeph_config::ContextStrategy::FullHistory
146 }
147 }
148 _ => zeph_config::ContextStrategy::FullHistory,
149 }
150}
151
152fn correction_params(cfg: Option<&crate::input::CorrectionConfig>) -> (usize, f32) {
153 cfg.filter(|c| c.correction_detection)
154 .map_or((3, 0.75), |c| {
155 (
156 c.correction_recall_limit as usize,
157 c.correction_min_similarity,
158 )
159 })
160}
161
162#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
171fn schedule_context_fetchers<'r>(
172 memory: &'r crate::input::ContextMemoryView,
173 tc: &'r dyn TokenCounting,
174 query: &'r str,
175 scrub: fn(&str) -> std::borrow::Cow<'_, str>,
176 index: Option<&'r dyn crate::input::IndexAccess>,
177 router_ref: &'r dyn AsyncMemoryRouter,
178 alloc: &'r BudgetAllocation,
179 recall_limit: usize,
180 min_sim: f32,
181 active_levels: &[CompressionLevel],
182 active_types: &[FunctionalType],
183) -> FuturesUnordered<CtxFuture<'r>> {
184 let (episodic_active, procedural_active, declarative_active) = levels_to_flags(active_levels);
194
195 let fetchers: FuturesUnordered<CtxFuture<'r>> = FuturesUnordered::new();
196
197 if episodic_active
198 && alloc.summaries > 0
199 && type_active(active_types, FunctionalType::CrossSessionSummary)
200 {
201 fetchers.push(Box::pin(async move {
202 fetch_summaries(memory, alloc.summaries, tc)
203 .await
204 .map(ContextSlot::Summaries)
205 }));
206 }
207 if episodic_active
208 && alloc.cross_session > 0
209 && type_active(active_types, FunctionalType::CrossSessionSummary)
210 {
211 fetchers.push(Box::pin(async move {
212 fetch_cross_session(memory, query, alloc.cross_session, tc)
213 .await
214 .map(ContextSlot::CrossSession)
215 }));
216 }
217 if episodic_active
218 && alloc.semantic_recall > 0
219 && type_active(active_types, FunctionalType::Episodic)
220 {
221 fetchers.push(Box::pin(async move {
222 fetch_semantic_recall(memory, query, alloc.semantic_recall, tc, Some(router_ref))
223 .await
224 .map(|(msg, score)| ContextSlot::SemanticRecall(msg, score))
225 }));
226 }
227 if episodic_active && alloc.semantic_recall > 0 {
232 fetchers.push(Box::pin(async move {
233 fetch_document_rag(memory, query, alloc.semantic_recall, tc)
234 .await
235 .map(ContextSlot::DocumentRag)
236 }));
237 }
238 fetchers.push(Box::pin(async move {
240 fetch_corrections(memory, query, recall_limit, min_sim, scrub)
241 .await
242 .map(ContextSlot::Corrections)
243 }));
244 if alloc.code_context > 0
246 && let Some(idx) = index
247 {
248 fetchers.push(Box::pin(async move {
249 let result: Result<Option<String>, AssemblerError> = if let Ok(r) =
250 tokio::time::timeout(
251 std::time::Duration::from_millis(MEMORY_FETCH_TIMEOUT_MS),
252 idx.fetch_code_rag(query, alloc.code_context),
253 )
254 .await
255 {
256 r
257 } else {
258 tracing::warn!("code RAG fetch timed out ({MEMORY_FETCH_TIMEOUT_MS}ms)");
259 Ok(None)
260 };
261 result.map(ContextSlot::CodeContext)
262 }));
263 }
264 if declarative_active
265 && alloc.graph_facts > 0
266 && type_active(active_types, FunctionalType::GraphFact)
267 {
268 fetchers.push(Box::pin(async move {
269 fetch_graph_facts(memory, query, alloc.graph_facts, tc)
270 .await
271 .map(ContextSlot::GraphFacts)
272 }));
273 }
274 if declarative_active
275 && memory.persona_config.context_budget_tokens > 0
276 && type_active(active_types, FunctionalType::UserFact)
277 {
278 fetchers.push(Box::pin(async move {
279 let persona_budget = memory.persona_config.context_budget_tokens;
280 fetch_persona_facts(memory, persona_budget, tc)
281 .await
282 .map(ContextSlot::PersonaFacts)
283 }));
284 }
285 if procedural_active && memory.trajectory_config.context_budget_tokens > 0 {
288 fetchers.push(Box::pin(async move {
289 let tbudget = memory.trajectory_config.context_budget_tokens;
290 fetch_trajectory_hints(memory, tbudget, tc)
291 .await
292 .map(ContextSlot::TrajectoryHints)
293 }));
294 }
295 if declarative_active && memory.tree_config.context_budget_tokens > 0 {
298 fetchers.push(Box::pin(async move {
299 let tbudget = memory.tree_config.context_budget_tokens;
300 fetch_tree_memory(memory, tbudget, tc)
301 .await
302 .map(ContextSlot::TreeMemory)
303 }));
304 }
305 if procedural_active
306 && memory.reasoning_config.enabled
307 && memory.reasoning_config.context_budget_tokens > 0
308 && type_active(active_types, FunctionalType::ReasoningStrategy)
309 {
310 fetchers.push(Box::pin(async move {
311 let rbudget = memory.reasoning_config.context_budget_tokens;
312 let top_k = memory.reasoning_config.top_k;
313 fetch_reasoning_strategies(memory, query, rbudget, top_k, tc)
314 .await
315 .map(|(msg, handle)| ContextSlot::ReasoningStrategies(msg, handle))
316 }));
317 }
318
319 fetchers
320}
321
322async fn drive_fetchers(
323 mut fetchers: FuturesUnordered<CtxFuture<'_>>,
324 prepared: &mut PreparedContext,
325) -> Result<(), AssemblerError> {
326 while let Some(result) = fetchers.next().await {
327 match result {
328 Ok(slot) => match slot {
329 ContextSlot::Summaries(msg) => prepared.summaries = msg,
330 ContextSlot::CrossSession(msg) => prepared.cross_session = msg,
331 ContextSlot::SemanticRecall(msg, score) => {
332 prepared.recall = msg;
333 prepared.recall_confidence = score;
334 }
335 ContextSlot::DocumentRag(msg) => prepared.doc_rag = msg,
336 ContextSlot::Corrections(msg) => prepared.corrections = msg,
337 ContextSlot::CodeContext(text) => prepared.code_context = text,
338 ContextSlot::GraphFacts(msg) => prepared.graph_facts = msg,
339 ContextSlot::PersonaFacts(msg) => prepared.persona_facts = msg,
340 ContextSlot::TrajectoryHints(msg) => prepared.trajectory_hints = msg,
341 ContextSlot::TreeMemory(msg) => prepared.tree_memory = msg,
342 ContextSlot::ReasoningStrategies(msg, handle) => {
343 prepared.reasoning_hints = msg;
344 if let Some(h) = handle {
345 prepared.background_tasks.push(h);
346 }
347 }
348 },
349 Err(e) => return Err(e),
350 }
351 }
352 Ok(())
353}
354
355impl ContextAssembler {
356 #[tracing::instrument(
364 name = "context.assembler.gather",
365 skip_all,
366 fields(active_types = ?input.active_types)
367 )]
368 pub async fn gather(
369 input: &ContextAssemblyInput<'_>,
370 ) -> Result<PreparedContext, AssemblerError> {
371 let Some(ref budget) = input.context_manager.budget else {
372 return Ok(empty_prepared_context());
373 };
374
375 let memory = input.memory;
376 let tc = input.token_counter;
377
378 let effective_strategy = resolve_effective_strategy(memory, input.sidequest_turn_counter);
379 let memory_first = effective_strategy == zeph_config::ContextStrategy::MemoryFirst;
380
381 let system_prompt = input
382 .messages
383 .first()
384 .filter(|m| m.role == Role::System)
385 .map_or("", |m| m.content.as_str());
386
387 let digest_tokens = memory
388 .cached_session_digest
389 .as_ref()
390 .map_or(0, |(_, tokens)| *tokens);
391
392 let alloc = budget.allocate_with_opts(
393 system_prompt,
394 input.skills_prompt,
395 tc,
396 memory.graph_config.enabled,
397 digest_tokens,
398 memory_first,
399 );
400
401 let (recall_limit, min_sim) = correction_params(input.correction_config.as_ref());
402
403 let router_ref: &dyn AsyncMemoryRouter = input.router.as_ref();
404
405 tracing::debug!(
406 active_sources = alloc.active_sources(),
407 active_levels = ?input.active_levels,
408 "context budget allocated"
409 );
410
411 let fetchers = schedule_context_fetchers(
412 memory,
413 tc,
414 input.query,
415 input.scrub,
416 input.index,
417 router_ref,
418 &alloc,
419 recall_limit,
420 min_sim,
421 input.active_levels,
422 input.active_types,
423 );
424
425 let mut prepared = empty_prepared_context();
426 prepared.memory_first = memory_first;
427 prepared.recent_history_budget = alloc.recent_history;
428
429 drive_fetchers(fetchers, &mut prepared).await?;
430 Ok(prepared)
431 }
432}
433
434pub fn effective_recall_timeout_ms(configured: u64) -> u64 {
439 if configured == 0 {
440 tracing::warn!(
441 "recall_timeout_ms is 0, which would disable spreading activation recall; \
442 clamping to 100ms"
443 );
444 100
445 } else {
446 configured
447 }
448}
449
450use crate::input::ContextMemoryView;
451
452#[tracing::instrument(name = "context.graph_facts", skip_all)]
453#[allow(clippy::too_many_lines)] pub(crate) async fn fetch_graph_facts(
455 memory: &ContextMemoryView,
456 query: &str,
457 budget_tokens: usize,
458 tc: &dyn TokenCounting,
459) -> Result<Option<Message>, AssemblerError> {
460 use zeph_common::memory::{RecallView, SpreadingActivationParams, classify_graph_subgraph};
461
462 if budget_tokens == 0 || !memory.graph_config.enabled {
463 return Ok(None);
464 }
465 let Some(ref mem) = memory.memory else {
466 return Ok(None);
467 };
468 let recall_limit = memory.graph_config.recall_limit;
469 let temporal_decay_rate = memory.graph_config.temporal_decay_rate;
470 let sa_config = &memory.graph_config.spreading_activation;
471
472 let fused_query;
474 let effective_query = if let Some(ref state) = memory.memcot_state {
475 let max_state_chars = 2 * query.len();
476 let state_slice = if state.len() > max_state_chars {
477 let boundary = state.floor_char_boundary(max_state_chars);
478 &state[..boundary]
479 } else {
480 state.as_str()
481 };
482 fused_query = format!("[state] {state_slice}\n{query}");
483 &fused_query as &str
484 } else {
485 query
486 };
487
488 let edge_types = classify_graph_subgraph(effective_query);
489
490 let view = match memory.memcot_config.recall_view {
491 zeph_config::RecallViewConfig::ZoomIn => RecallView::ZoomIn,
492 zeph_config::RecallViewConfig::ZoomOut => RecallView::ZoomOut,
493 _ => RecallView::Head,
494 };
495
496 let sa_params = Some(SpreadingActivationParams {
499 decay_lambda: sa_config.decay_lambda,
500 max_hops: sa_config.max_hops,
501 activation_threshold: sa_config.activation_threshold,
502 inhibition_threshold: sa_config.inhibition_threshold,
503 max_activated_nodes: sa_config.max_activated_nodes,
504 temporal_decay_rate,
505 seed_structural_weight: sa_config.seed_structural_weight,
506 seed_community_cap: sa_config.seed_community_cap,
507 alpha: sa_config.alpha,
508 });
509
510 let retrieval_strategy = if sa_config.enabled {
513 zeph_common::memory::GraphRetrievalStrategy::Synapse
514 } else {
515 match memory.graph_config.retrieval_strategy {
516 zeph_config::memory::GraphRetrievalStrategy::Synapse => {
517 zeph_common::memory::GraphRetrievalStrategy::Synapse
518 }
519 zeph_config::memory::GraphRetrievalStrategy::Bfs => {
520 zeph_common::memory::GraphRetrievalStrategy::Bfs
521 }
522 zeph_config::memory::GraphRetrievalStrategy::AStar => {
523 zeph_common::memory::GraphRetrievalStrategy::AStar
524 }
525 zeph_config::memory::GraphRetrievalStrategy::WaterCircles => {
526 zeph_common::memory::GraphRetrievalStrategy::WaterCircles
527 }
528 zeph_config::memory::GraphRetrievalStrategy::BeamSearch => {
529 zeph_common::memory::GraphRetrievalStrategy::BeamSearch
530 }
531 zeph_config::memory::GraphRetrievalStrategy::Hybrid => {
532 zeph_common::memory::GraphRetrievalStrategy::Hybrid
533 }
534 _ => zeph_common::memory::GraphRetrievalStrategy::Synapse,
535 }
536 };
537
538 let timeout_ms = effective_recall_timeout_ms(sa_config.recall_timeout_ms);
539 let recall_fut = mem.recall_graph_facts(
540 effective_query,
541 GraphRecallParams {
542 limit: recall_limit,
543 view,
544 zoom_out_neighbor_cap: memory.memcot_config.zoom_out_neighbor_cap,
545 max_hops: memory.graph_config.max_hops,
546 temporal_decay_rate,
547 edge_types: &edge_types,
548 spreading_activation: sa_params,
549 retrieval_strategy,
550 beam_width: memory.graph_config.beam_search.beam_width,
551 ring_limit: memory.graph_config.watercircles.ring_limit,
552 },
553 );
554 let recalled = match tokio::time::timeout(
555 std::time::Duration::from_millis(timeout_ms),
556 recall_fut,
557 )
558 .await
559 {
560 Ok(Ok(facts)) => facts,
561 Ok(Err(e)) => {
562 tracing::warn!("graph recall failed: {e:#}");
563 Vec::new()
564 }
565 Err(_) => {
566 tracing::warn!("graph recall timed out ({timeout_ms}ms)");
567 Vec::new()
568 }
569 };
570
571 if recalled.is_empty() {
572 return Ok(None);
573 }
574
575 let mut body = String::from(GRAPH_FACTS_PREFIX);
576 let mut tokens_so_far = tc.count_tokens(&body);
577
578 for rf in &recalled {
579 let fact_text = rf.fact.replace(['\n', '\r', '<', '>'], " ");
580 let line = if let Some(score) = rf.activation_score {
581 format!(
582 "- {} (confidence: {:.2}, activation: {:.2})\n",
583 fact_text, rf.confidence, score
584 )
585 } else {
586 format!("- {} (confidence: {:.2})\n", fact_text, rf.confidence)
587 };
588 let line_tokens = tc.count_tokens(&line);
589 if tokens_so_far + line_tokens > budget_tokens {
590 break;
591 }
592 body.push_str(&line);
593 tokens_so_far += line_tokens;
594
595 for nb in &rf.neighbors {
597 let nb_text = nb.fact.replace(['\n', '\r', '<', '>'], " ");
598 let nb_line = format!(" ~ {} (confidence: {:.2})\n", nb_text, nb.confidence);
599 let nb_tokens = tc.count_tokens(&nb_line);
600 if tokens_so_far + nb_tokens > budget_tokens {
601 break;
602 }
603 body.push_str(&nb_line);
604 tokens_so_far += nb_tokens;
605 }
606
607 if let Some(ref snippet) = rf.provenance_snippet {
609 let snip_line = format!(
610 " [source: {}]\n",
611 snippet.replace(['\n', '\r', '<', '>'], " ")
612 );
613 let snip_tokens = tc.count_tokens(&snip_line);
614 if tokens_so_far + snip_tokens <= budget_tokens {
615 body.push_str(&snip_line);
616 tokens_so_far += snip_tokens;
617 }
618 }
619 }
620
621 if body == GRAPH_FACTS_PREFIX {
622 return Ok(None);
623 }
624
625 Ok(Some(Message::from_legacy(Role::System, body)))
626}
627
628fn append_budgeted_lines(
635 prefix: &str,
636 lines: impl Iterator<Item = String>,
637 budget_tokens: usize,
638 tc: &dyn TokenCounting,
639) -> Option<String> {
640 let mut body = String::from(prefix);
641 let mut tokens_so_far = tc.count_tokens(&body);
642
643 for line in lines {
644 let line_tokens = tc.count_tokens(&line);
645 if tokens_so_far + line_tokens > budget_tokens {
646 break;
647 }
648 body.push_str(&line);
649 tokens_so_far += line_tokens;
650 }
651
652 if body == prefix { None } else { Some(body) }
653}
654
655async fn fetch_with_timeout<T>(
664 label: &str,
665 fut: impl Future<Output = Result<Vec<T>, Box<dyn std::error::Error + Send + Sync>>>,
666) -> Result<Vec<T>, AssemblerError> {
667 if let Ok(result) = tokio::time::timeout(
668 std::time::Duration::from_millis(MEMORY_FETCH_TIMEOUT_MS),
669 fut,
670 )
671 .await
672 {
673 result.map_err(AssemblerError::Memory)
674 } else {
675 tracing::warn!("{label} timed out ({MEMORY_FETCH_TIMEOUT_MS}ms)");
676 Ok(Vec::new())
677 }
678}
679
680#[tracing::instrument(name = "context.persona_facts", skip_all)]
681pub(crate) async fn fetch_persona_facts(
682 memory: &ContextMemoryView,
683 budget_tokens: usize,
684 tc: &dyn TokenCounting,
685) -> Result<Option<Message>, AssemblerError> {
686 if budget_tokens == 0 || !memory.persona_config.enabled {
687 return Ok(None);
688 }
689 let Some(ref mem) = memory.memory else {
690 return Ok(None);
691 };
692
693 let min_confidence = memory.persona_config.min_confidence;
694 let facts =
695 fetch_with_timeout("persona facts load", mem.load_persona_facts(min_confidence)).await?;
696
697 if facts.is_empty() {
698 return Ok(None);
699 }
700
701 let lines = facts
702 .iter()
703 .map(|fact| format!("[{}] {}\n", fact.category, fact.content));
704 Ok(
705 append_budgeted_lines(crate::slot::PERSONA_PREFIX, lines, budget_tokens, tc)
706 .map(|body| Message::from_legacy(Role::System, body)),
707 )
708}
709
710#[tracing::instrument(name = "context.trajectory_hints", skip_all)]
711pub(crate) async fn fetch_trajectory_hints(
712 memory: &ContextMemoryView,
713 budget_tokens: usize,
714 tc: &dyn TokenCounting,
715) -> Result<Option<Message>, AssemblerError> {
716 if budget_tokens == 0 || !memory.trajectory_config.enabled {
717 return Ok(None);
718 }
719 let Some(ref mem) = memory.memory else {
720 return Ok(None);
721 };
722
723 let top_k = memory.trajectory_config.recall_top_k;
724 let min_conf = memory.trajectory_config.min_confidence;
725 let entries = fetch_with_timeout(
729 "trajectory entries load",
730 mem.load_trajectory_entries(Some("procedural"), top_k),
731 )
732 .await?;
733
734 if entries.is_empty() {
735 return Ok(None);
736 }
737
738 let lines = entries
739 .iter()
740 .filter(|e| e.confidence >= min_conf)
741 .take(top_k)
742 .map(|entry| format!("- {}: {}\n", entry.intent, entry.outcome));
743 Ok(
744 append_budgeted_lines(crate::slot::TRAJECTORY_PREFIX, lines, budget_tokens, tc)
745 .map(|body| Message::from_legacy(Role::System, body)),
746 )
747}
748
749#[tracing::instrument(name = "context.tree_memory", skip_all)]
750pub(crate) async fn fetch_tree_memory(
751 memory: &ContextMemoryView,
752 budget_tokens: usize,
753 tc: &dyn TokenCounting,
754) -> Result<Option<Message>, AssemblerError> {
755 if budget_tokens == 0 || !memory.tree_config.enabled {
756 return Ok(None);
757 }
758 let Some(ref mem) = memory.memory else {
759 return Ok(None);
760 };
761
762 let top_k = memory.tree_config.recall_top_k;
763 let nodes = fetch_with_timeout("tree nodes load", mem.load_tree_nodes(1, top_k)).await?;
764
765 if nodes.is_empty() {
766 return Ok(None);
767 }
768
769 let lines = nodes
770 .iter()
771 .take(top_k)
772 .map(|node| format!("- {}\n", node.content));
773 Ok(
774 append_budgeted_lines(crate::slot::TREE_MEMORY_PREFIX, lines, budget_tokens, tc)
775 .map(|body| Message::from_legacy(Role::System, body)),
776 )
777}
778
779#[tracing::instrument(name = "context.reasoning_strategies", skip_all)]
780pub(crate) async fn fetch_reasoning_strategies(
781 memory: &ContextMemoryView,
782 query: &str,
783 budget_tokens: usize,
784 top_k: usize,
785 tc: &dyn TokenCounting,
786) -> Result<(Option<Message>, Option<tokio::task::JoinHandle<()>>), AssemblerError> {
787 let budget_tokens = budget_tokens.min(500);
789 if budget_tokens == 0 {
790 return Ok((None, None));
791 }
792 let Some(ref mem) = memory.memory else {
793 return Ok((None, None));
794 };
795
796 let strategies = fetch_with_timeout(
797 "reasoning strategies retrieval",
798 mem.retrieve_reasoning_strategies(query, top_k),
799 )
800 .await?;
801
802 if strategies.is_empty() {
803 return Ok((None, None));
804 }
805
806 let mut body = String::from(crate::slot::REASONING_PREFIX);
807 let mut tokens_so_far = tc.count_tokens(&body);
808 let mut injected_ids: Vec<String> = Vec::new();
809
810 for s in strategies.iter().take(top_k) {
811 let safe_summary = s.summary.replace(['\n', '\r', '<', '>'], " ");
814 let line = format!("- [{}] {}\n", s.outcome, safe_summary);
815 let line_tokens = tc.count_tokens(&line);
816 if tokens_so_far + line_tokens > budget_tokens {
817 break;
818 }
819 body.push_str(&line);
820 tokens_so_far += line_tokens;
821 injected_ids.push(s.id.clone());
822 }
823
824 if body == crate::slot::REASONING_PREFIX {
825 return Ok((None, None));
826 }
827
828 let handle = if injected_ids.is_empty() {
832 None
833 } else {
834 let mem_clone = mem.clone();
835 let mark_used = async move {
836 if let Err(e) = mem_clone.mark_reasoning_used(&injected_ids).await {
837 tracing::warn!(error = %e, "reasoning: mark_used failed");
838 }
839 };
840 Some(tokio::spawn(mark_used)) };
842
843 Ok((Some(Message::from_legacy(Role::System, body)), handle))
844}
845
846#[tracing::instrument(name = "context.corrections", skip_all)]
847pub(crate) async fn fetch_corrections(
848 memory: &ContextMemoryView,
849 query: &str,
850 limit: usize,
851 min_score: f32,
852 scrub: fn(&str) -> std::borrow::Cow<'_, str>,
853) -> Result<Option<Message>, AssemblerError> {
854 let Some(ref mem) = memory.memory else {
855 return Ok(None);
856 };
857 let corrections = fetch_with_timeout(
858 "corrections retrieval",
859 mem.retrieve_corrections(query, limit, min_score),
860 )
861 .await?;
862 if corrections.is_empty() {
863 return Ok(None);
864 }
865 let mut text = String::from(CORRECTIONS_PREFIX);
866 for c in &corrections {
867 text.push_str("- Past user correction: \"");
868 text.push_str(&scrub(&c.correction_text));
869 text.push_str("\"\n");
870 }
871 Ok(Some(Message::from_legacy(Role::System, text)))
872}
873
874#[tracing::instrument(name = "context.semantic_recall", skip_all)]
875pub(crate) async fn fetch_semantic_recall(
876 memory: &ContextMemoryView,
877 query: &str,
878 token_budget: usize,
879 tc: &dyn TokenCounting,
880 router: Option<&dyn AsyncMemoryRouter>,
881) -> Result<(Option<Message>, Option<f32>), AssemblerError> {
882 let Some(ref mem) = memory.memory else {
883 return Ok((None, None));
884 };
885 if memory.recall_limit == 0 || token_budget == 0 {
886 return Ok((None, None));
887 }
888
889 let recalled = fetch_with_timeout(
890 "semantic recall",
891 mem.recall(query, memory.recall_limit, router),
892 )
893 .await?;
894 if recalled.is_empty() {
895 return Ok((None, None));
896 }
897
898 let top_score = recalled.first().map(|r| r.score);
899
900 let lines = recalled
901 .iter()
902 .filter(|item| {
903 !item.content.starts_with("[skipped]") && !item.content.starts_with("[stopped]")
904 })
905 .map(|item| format!("- [{}] {}\n", item.role, item.content));
906
907 match append_budgeted_lines(RECALL_PREFIX, lines, token_budget, tc) {
908 Some(text) => Ok((
909 Some(Message::from_parts(
910 Role::System,
911 vec![MessagePart::Recall { text }],
912 )),
913 top_score,
914 )),
915 None => Ok((None, None)),
916 }
917}
918
919#[tracing::instrument(name = "context.document_rag", skip_all)]
920pub(crate) async fn fetch_document_rag(
921 memory: &ContextMemoryView,
922 query: &str,
923 token_budget: usize,
924 tc: &dyn TokenCounting,
925) -> Result<Option<Message>, AssemblerError> {
926 if !memory.document_config.rag_enabled || token_budget == 0 {
927 return Ok(None);
928 }
929 let Some(ref mem) = memory.memory else {
930 return Ok(None);
931 };
932
933 let collection = &memory.document_config.collection;
934 let top_k = memory.document_config.top_k;
935 let chunks = fetch_with_timeout(
936 "document RAG search",
937 mem.search_document_collection(collection, query, top_k),
938 )
939 .await?;
940 if chunks.is_empty() {
941 return Ok(None);
942 }
943
944 let lines = chunks
945 .iter()
946 .filter(|chunk| !chunk.text.is_empty())
947 .map(|chunk| format!("{}\n", chunk.text));
948
949 Ok(
950 append_budgeted_lines(DOCUMENT_RAG_PREFIX, lines, token_budget, tc).map(|text| Message {
951 role: Role::System,
952 content: text,
953 parts: vec![],
954 metadata: MessageMetadata::default(),
955 }),
956 )
957}
958
959#[tracing::instrument(name = "context.summaries", skip_all)]
960pub(crate) async fn fetch_summaries(
961 memory: &ContextMemoryView,
962 token_budget: usize,
963 tc: &dyn TokenCounting,
964) -> Result<Option<Message>, AssemblerError> {
965 let (Some(mem), Some(cid)) = (&memory.memory, memory.conversation_id) else {
966 return Ok(None);
967 };
968 if token_budget == 0 {
969 return Ok(None);
970 }
971
972 let summaries = fetch_with_timeout("summaries load", mem.load_summaries(cid)).await?;
973 if summaries.is_empty() {
974 return Ok(None);
975 }
976
977 let lines = summaries.iter().rev().map(|summary| {
978 let first = summary.first_message_id.unwrap_or(0);
979 let last = summary.last_message_id.unwrap_or(0);
980 format!("- Messages {first}-{last}: {}\n", summary.content)
981 });
982
983 Ok(
984 append_budgeted_lines(SUMMARY_PREFIX, lines, token_budget, tc)
985 .map(|text| Message::from_parts(Role::System, vec![MessagePart::Summary { text }])),
986 )
987}
988
989#[tracing::instrument(name = "context.cross_session", skip_all)]
990pub(crate) async fn fetch_cross_session(
991 memory: &ContextMemoryView,
992 query: &str,
993 token_budget: usize,
994 tc: &dyn TokenCounting,
995) -> Result<Option<Message>, AssemblerError> {
996 let (Some(mem), Some(cid)) = (&memory.memory, memory.conversation_id) else {
997 return Ok(None);
998 };
999 if token_budget == 0 {
1000 return Ok(None);
1001 }
1002
1003 let threshold = memory.cross_session_score_threshold;
1004 let summaries = fetch_with_timeout(
1005 "cross-session search",
1006 mem.search_session_summaries(query, 5, Some(cid)),
1007 )
1008 .await?;
1009 let results: Vec<_> = summaries
1010 .into_iter()
1011 .filter(|r| r.score >= threshold)
1012 .collect();
1013 if results.is_empty() {
1014 return Ok(None);
1015 }
1016
1017 let lines = results
1018 .iter()
1019 .map(|item| format!("- {}\n", item.summary_text));
1020
1021 Ok(
1022 append_budgeted_lines(CROSS_SESSION_PREFIX, lines, token_budget, tc).map(|text| {
1023 Message::from_parts(Role::System, vec![MessagePart::CrossSession { text }])
1024 }),
1025 )
1026}
1027
1028pub const MAX_KEEP_TAIL_SCAN: usize = 50;
1031
1032#[must_use]
1040pub fn memory_first_keep_tail(messages: &[Message], history_start: usize) -> usize {
1041 use zeph_llm::provider::MessagePart;
1042
1043 let mut keep_tail = 2usize;
1044 let len = messages.len();
1045 let max = len.saturating_sub(history_start);
1046
1047 while keep_tail < max {
1048 let first_retained = &messages[len - keep_tail];
1049 let is_tool_result = first_retained.role == Role::User
1050 && first_retained
1051 .parts
1052 .iter()
1053 .any(|p| matches!(p, MessagePart::ToolResult { .. }));
1054
1055 if is_tool_result {
1056 keep_tail += 1;
1057 } else {
1058 break;
1059 }
1060
1061 if keep_tail >= MAX_KEEP_TAIL_SCAN {
1062 let preceding_idx = len.saturating_sub(keep_tail + 1);
1063 if preceding_idx >= history_start {
1064 let preceding = &messages[preceding_idx];
1065 let is_tool_use = preceding.role == Role::Assistant
1066 && preceding
1067 .parts
1068 .iter()
1069 .any(|p| matches!(p, MessagePart::ToolUse { .. }));
1070 if is_tool_use {
1071 keep_tail += 1;
1072 }
1073 }
1074 break;
1075 }
1076 }
1077
1078 keep_tail
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use crate::input::ContextMemoryView;
1085 use zeph_common::memory::CompressionLevel;
1086 use zeph_config::{
1087 ContextStrategy, DocumentConfig, GraphConfig, PersonaConfig, ReasoningConfig,
1088 TrajectoryConfig, TreeConfig,
1089 };
1090
1091 struct NaiveTokenCounter;
1092 impl zeph_common::memory::TokenCounting for NaiveTokenCounter {
1093 fn count_tokens(&self, text: &str) -> usize {
1094 text.split_whitespace().count()
1095 }
1096 fn count_tool_schema_tokens(&self, schema: &serde_json::Value) -> usize {
1097 schema.to_string().split_whitespace().count()
1098 }
1099 }
1100
1101 fn empty_view() -> ContextMemoryView {
1102 ContextMemoryView {
1103 memory: None,
1104 conversation_id: None,
1105 recall_limit: 10,
1106 cross_session_score_threshold: 0.5,
1107 context_strategy: ContextStrategy::default(),
1108 crossover_turn_threshold: 5,
1109 cached_session_digest: None,
1110 graph_config: GraphConfig::default(),
1111 document_config: DocumentConfig::default(),
1112 persona_config: PersonaConfig::default(),
1113 trajectory_config: TrajectoryConfig::default(),
1114 reasoning_config: ReasoningConfig::default(),
1115 memcot_config: zeph_config::MemCotConfig::default(),
1116 memcot_state: None,
1117 tree_config: TreeConfig::default(),
1118 }
1119 }
1120
1121 #[tokio::test]
1124 async fn fetch_graph_facts_returns_none_when_memory_is_none() {
1125 let view = empty_view();
1126 let tc = NaiveTokenCounter;
1127 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1128 assert!(result.is_none());
1129 }
1130
1131 #[tokio::test]
1132 async fn fetch_graph_facts_returns_none_when_budget_zero() {
1133 let mut view = empty_view();
1134 view.graph_config.enabled = true;
1135 let tc = NaiveTokenCounter;
1136 let result = fetch_graph_facts(&view, "test", 0, &tc).await.unwrap();
1137 assert!(result.is_none());
1138 }
1139
1140 #[tokio::test]
1141 async fn fetch_graph_facts_returns_none_when_graph_disabled() {
1142 let mut view = empty_view();
1143 view.graph_config.enabled = false;
1144 let tc = NaiveTokenCounter;
1145 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1146 assert!(result.is_none());
1147 }
1148
1149 #[tokio::test]
1152 async fn fetch_persona_facts_returns_none_when_memory_is_none() {
1153 let view = empty_view();
1154 let tc = NaiveTokenCounter;
1155 let result = fetch_persona_facts(&view, 1000, &tc).await.unwrap();
1156 assert!(result.is_none());
1157 }
1158
1159 #[tokio::test]
1160 async fn fetch_persona_facts_returns_none_when_budget_zero() {
1161 let mut view = empty_view();
1162 view.persona_config.enabled = true;
1163 let tc = NaiveTokenCounter;
1164 let result = fetch_persona_facts(&view, 0, &tc).await.unwrap();
1165 assert!(result.is_none());
1166 }
1167
1168 #[tokio::test]
1171 async fn fetch_trajectory_hints_returns_none_when_memory_is_none() {
1172 let view = empty_view();
1173 let tc = NaiveTokenCounter;
1174 let result = fetch_trajectory_hints(&view, 1000, &tc).await.unwrap();
1175 assert!(result.is_none());
1176 }
1177
1178 #[tokio::test]
1179 async fn fetch_trajectory_hints_returns_none_when_budget_zero() {
1180 let mut view = empty_view();
1181 view.trajectory_config.enabled = true;
1182 let tc = NaiveTokenCounter;
1183 let result = fetch_trajectory_hints(&view, 0, &tc).await.unwrap();
1184 assert!(result.is_none());
1185 }
1186
1187 #[tokio::test]
1190 async fn fetch_tree_memory_returns_none_when_memory_is_none() {
1191 let view = empty_view();
1192 let tc = NaiveTokenCounter;
1193 let result = fetch_tree_memory(&view, 1000, &tc).await.unwrap();
1194 assert!(result.is_none());
1195 }
1196
1197 #[tokio::test]
1198 async fn fetch_tree_memory_returns_none_when_budget_zero() {
1199 let mut view = empty_view();
1200 view.tree_config.enabled = true;
1201 let tc = NaiveTokenCounter;
1202 let result = fetch_tree_memory(&view, 0, &tc).await.unwrap();
1203 assert!(result.is_none());
1204 }
1205
1206 #[tokio::test]
1209 async fn fetch_corrections_returns_none_when_memory_is_none() {
1210 let view = empty_view();
1211 let result = fetch_corrections(&view, "test", 10, 0.5, |s| s.into())
1212 .await
1213 .unwrap();
1214 assert!(result.is_none());
1215 }
1216
1217 #[tokio::test]
1220 async fn fetch_semantic_recall_returns_none_when_memory_is_none() {
1221 let view = empty_view();
1222 let tc = NaiveTokenCounter;
1223 let result = fetch_semantic_recall(&view, "test", 1000, &tc, None)
1224 .await
1225 .unwrap();
1226 assert!(result.0.is_none() && result.1.is_none());
1227 }
1228
1229 #[tokio::test]
1230 async fn fetch_semantic_recall_returns_none_when_budget_zero() {
1231 let view = empty_view();
1232 let tc = NaiveTokenCounter;
1233 let result = fetch_semantic_recall(&view, "test", 0, &tc, None)
1234 .await
1235 .unwrap();
1236 assert!(result.0.is_none() && result.1.is_none());
1237 }
1238
1239 #[tokio::test]
1242 async fn fetch_document_rag_returns_none_when_memory_is_none() {
1243 let mut view = empty_view();
1244 view.document_config.rag_enabled = true;
1245 let tc = NaiveTokenCounter;
1246 let result = fetch_document_rag(&view, "test", 1000, &tc).await.unwrap();
1247 assert!(result.is_none());
1248 }
1249
1250 #[tokio::test]
1251 async fn fetch_document_rag_returns_none_when_rag_disabled() {
1252 let view = empty_view();
1253 let tc = NaiveTokenCounter;
1254 let result = fetch_document_rag(&view, "test", 1000, &tc).await.unwrap();
1255 assert!(result.is_none());
1256 }
1257
1258 #[tokio::test]
1261 async fn fetch_summaries_returns_none_when_memory_is_none() {
1262 let view = empty_view();
1263 let tc = NaiveTokenCounter;
1264 let result = fetch_summaries(&view, 1000, &tc).await.unwrap();
1265 assert!(result.is_none());
1266 }
1267
1268 #[tokio::test]
1271 async fn fetch_cross_session_returns_none_when_memory_is_none() {
1272 let view = empty_view();
1273 let tc = NaiveTokenCounter;
1274 let result = fetch_cross_session(&view, "test", 1000, &tc).await.unwrap();
1275 assert!(result.is_none());
1276 }
1277
1278 #[test]
1281 fn levels_to_flags_empty_slice_enables_all_tiers() {
1282 let (e, p, d) = levels_to_flags(&[]);
1283 assert!(e, "episodic should be active for empty slice");
1284 assert!(p, "procedural should be active for empty slice");
1285 assert!(d, "declarative should be active for empty slice");
1286 }
1287
1288 #[test]
1289 fn levels_to_flags_full_set_enables_all_tiers() {
1290 let all = &[
1291 CompressionLevel::Episodic,
1292 CompressionLevel::Procedural,
1293 CompressionLevel::Declarative,
1294 ];
1295 let (e, p, d) = levels_to_flags(all);
1296 assert!(e);
1297 assert!(p);
1298 assert!(d);
1299 }
1300
1301 #[test]
1302 fn levels_to_flags_episodic_only() {
1303 let (e, p, d) = levels_to_flags(&[CompressionLevel::Episodic]);
1304 assert!(e);
1305 assert!(!p, "procedural should be inactive");
1306 assert!(!d, "declarative should be inactive");
1307 }
1308
1309 #[test]
1310 fn levels_to_flags_episodic_and_procedural() {
1311 let (e, p, d) =
1312 levels_to_flags(&[CompressionLevel::Episodic, CompressionLevel::Procedural]);
1313 assert!(e);
1314 assert!(p);
1315 assert!(!d, "declarative should be inactive");
1316 }
1317
1318 #[test]
1319 fn levels_to_flags_declarative_only() {
1320 let (e, p, d) = levels_to_flags(&[CompressionLevel::Declarative]);
1321 assert!(!e, "episodic should be inactive");
1322 assert!(!p, "procedural should be inactive");
1323 assert!(d);
1324 }
1325
1326 #[test]
1329 fn type_active_empty_active_set_means_all_types() {
1330 assert!(type_active(&[], FunctionalType::Episodic));
1331 assert!(type_active(&[], FunctionalType::GraphFact));
1332 assert!(type_active(&[], FunctionalType::BehavioralRule));
1333 }
1334
1335 #[test]
1336 fn type_active_nonempty_set_gates_by_membership() {
1337 let active = [FunctionalType::UserFact];
1338 assert!(type_active(&active, FunctionalType::UserFact));
1339 assert!(!type_active(&active, FunctionalType::Episodic));
1340 assert!(!type_active(&active, FunctionalType::GraphFact));
1341 }
1342
1343 struct NoopRouter;
1346 impl zeph_common::memory::MemoryRouter for NoopRouter {
1347 fn route(&self, _query: &str) -> zeph_common::memory::MemoryRoute {
1348 zeph_common::memory::MemoryRoute::default()
1349 }
1350 }
1351 impl AsyncMemoryRouter for NoopRouter {
1352 fn route_async<'a>(
1353 &'a self,
1354 _query: &'a str,
1355 ) -> std::pin::Pin<
1356 Box<dyn std::future::Future<Output = zeph_common::memory::RoutingDecision> + Send + 'a>,
1357 > {
1358 Box::pin(async move {
1359 zeph_common::memory::RoutingDecision {
1360 route: zeph_common::memory::MemoryRoute::default(),
1361 confidence: 1.0,
1362 reasoning: None,
1363 }
1364 })
1365 }
1366 }
1367
1368 fn full_active_view() -> ContextMemoryView {
1372 let mut view = empty_view();
1373 view.persona_config.context_budget_tokens = 100;
1374 view.trajectory_config.context_budget_tokens = 100;
1375 view.tree_config.context_budget_tokens = 100;
1376 view.reasoning_config.enabled = true;
1377 view.reasoning_config.context_budget_tokens = 100;
1378 view.document_config.rag_enabled = true;
1379 view
1380 }
1381
1382 fn test_budget_alloc() -> crate::budget::BudgetAllocation {
1385 crate::budget::BudgetAllocation {
1386 system_prompt: 0,
1387 skills: 0,
1388 summaries: 100,
1389 semantic_recall: 100,
1390 cross_session: 100,
1391 code_context: 100,
1392 graph_facts: 100,
1393 recent_history: 0,
1394 response_reserve: 0,
1395 session_digest: 0,
1396 }
1397 }
1398
1399 fn schedule_all_budgeted<'r>(
1400 view: &'r ContextMemoryView,
1401 tc: &'r NaiveTokenCounter,
1402 router: &'r NoopRouter,
1403 alloc: &'r crate::budget::BudgetAllocation,
1404 active_types: &'r [FunctionalType],
1405 ) -> FuturesUnordered<CtxFuture<'r>> {
1406 schedule_context_fetchers(
1407 view,
1408 tc,
1409 "query",
1410 |s| s.into(),
1411 None,
1412 router,
1413 alloc,
1414 10,
1415 0.5,
1416 &[],
1417 active_types,
1418 )
1419 }
1420
1421 #[test]
1422 fn schedule_context_fetchers_schedules_everything_when_active_types_empty() {
1423 let view = full_active_view();
1424 let tc = NaiveTokenCounter;
1425 let router = NoopRouter;
1426 let alloc = test_budget_alloc();
1427 let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &[]);
1428 assert_eq!(fetchers.len(), 10);
1432 }
1433
1434 #[test]
1435 fn schedule_context_fetchers_gates_to_user_fact_only_sc1() {
1436 let view = full_active_view();
1441 let tc = NaiveTokenCounter;
1442 let router = NoopRouter;
1443 let active = [FunctionalType::UserFact];
1444 let alloc = test_budget_alloc();
1445 let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &active);
1446 assert_eq!(fetchers.len(), 5);
1448 }
1449
1450 #[test]
1451 fn schedule_context_fetchers_document_rag_survives_episodic_exclusion_n2() {
1452 let view = full_active_view();
1456 let tc = NaiveTokenCounter;
1457 let router = NoopRouter;
1458 let active = [FunctionalType::GraphFact];
1459 let alloc = test_budget_alloc();
1460 let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &active);
1461 assert_eq!(fetchers.len(), 5);
1464 }
1465
1466 #[test]
1467 fn schedule_context_fetchers_gates_cross_session_summary_both_slots() {
1468 let view = full_active_view();
1470 let tc = NaiveTokenCounter;
1471 let router = NoopRouter;
1472 let active = [FunctionalType::CrossSessionSummary];
1473 let alloc = test_budget_alloc();
1474 let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &active);
1475 assert_eq!(fetchers.len(), 6);
1477 }
1478
1479 #[tokio::test]
1488 async fn gather_with_user_fact_active_type_excludes_other_slots_sc4() {
1489 let mock = MockMemoryBackend {
1490 persona_facts: vec![MemPersonaFact {
1491 category: "preference".to_string(),
1492 content: "prefers concise answers".to_string(),
1493 }],
1494 ..Default::default()
1495 };
1496 let mut memory = mock_view(mock);
1497 memory.persona_config.enabled = true;
1498 memory.persona_config.context_budget_tokens = 1000;
1499 memory.graph_config.enabled = true;
1500 memory.reasoning_config.enabled = true;
1501 memory.reasoning_config.context_budget_tokens = 500;
1502 memory.document_config.rag_enabled = false;
1503
1504 let mut context_manager = crate::manager::ContextManager::new();
1505 context_manager.budget = Some(crate::budget::ContextBudget::new(128_000, 0.1));
1506
1507 let tc = NaiveTokenCounter;
1508 let active_types = [FunctionalType::UserFact];
1509
1510 let input = crate::input::ContextAssemblyInput {
1511 memory: &memory,
1512 context_manager: &context_manager,
1513 token_counter: &tc,
1514 skills_prompt: "",
1515 index: None,
1516 correction_config: None,
1517 sidequest_turn_counter: 0,
1518 messages: &[],
1519 query: "what do you know about me?",
1520 scrub: |s| s.into(),
1521 active_levels: &[],
1522 active_types: &active_types,
1523 router: Box::new(NoopRouter),
1524 planned_next_tools: &[],
1525 };
1526
1527 let prepared = ContextAssembler::gather(&input).await.unwrap();
1528
1529 assert!(
1530 prepared.recall.is_none(),
1531 "Episodic excluded from active set: recall must be None"
1532 );
1533 assert!(
1534 prepared.reasoning_hints.is_none(),
1535 "ReasoningStrategy excluded from active set: reasoning_hints must be None"
1536 );
1537 assert!(
1538 prepared.graph_facts.is_none(),
1539 "GraphFact excluded from active set: graph_facts must be None"
1540 );
1541 assert!(
1542 prepared.summaries.is_none(),
1543 "CrossSessionSummary excluded from active set: summaries must be None"
1544 );
1545 assert!(
1546 prepared.persona_facts.is_some(),
1547 "UserFact is in the active set: persona_facts must be Some"
1548 );
1549 }
1550
1551 #[tokio::test]
1554 async fn fetch_reasoning_strategies_returns_none_when_memory_is_none() {
1555 let mut view = empty_view();
1556 view.reasoning_config.enabled = true;
1557 let tc = NaiveTokenCounter;
1558 let (result, handle) = fetch_reasoning_strategies(&view, "query", 1000, 3, &tc)
1559 .await
1560 .unwrap();
1561 assert!(result.is_none());
1562 assert!(handle.is_none());
1563 }
1564
1565 #[tokio::test]
1566 async fn fetch_reasoning_strategies_returns_none_when_budget_zero() {
1567 let mut view = empty_view();
1568 view.reasoning_config.enabled = true;
1569 let tc = NaiveTokenCounter;
1570 let (result, handle) = fetch_reasoning_strategies(&view, "query", 0, 3, &tc)
1571 .await
1572 .unwrap();
1573 assert!(result.is_none());
1574 assert!(handle.is_none());
1575 }
1576
1577 use std::sync::{Arc, Mutex};
1580 use zeph_common::memory::{
1581 ContextMemoryBackend, GraphRecallParams, GraphRetrievalStrategy, MemCorrection,
1582 MemDocumentChunk, MemGraphFact, MemPersonaFact, MemReasoningStrategy, MemRecalledMessage,
1583 MemSessionSummary, MemSummary, MemTrajectoryEntry, MemTreeNode,
1584 };
1585
1586 const KNOWN_FAIL_ON: &[&str] = &[
1588 "load_persona_facts",
1589 "load_trajectory_entries",
1590 "load_tree_nodes",
1591 "load_summaries",
1592 "retrieve_reasoning_strategies",
1593 "mark_reasoning_used",
1594 "retrieve_corrections",
1595 "recall",
1596 "recall_graph_facts",
1597 "search_session_summaries",
1598 "search_document_collection",
1599 ];
1600
1601 #[derive(Default)]
1602 struct MockMemoryBackend {
1603 persona_facts: Vec<MemPersonaFact>,
1604 trajectory_entries: Vec<MemTrajectoryEntry>,
1605 tree_nodes: Vec<MemTreeNode>,
1606 summaries: Vec<MemSummary>,
1607 reasoning_strategies: Vec<MemReasoningStrategy>,
1608 corrections: Vec<MemCorrection>,
1609 recalled: Vec<MemRecalledMessage>,
1610 graph_facts: Vec<MemGraphFact>,
1611 session_summaries: Vec<MemSessionSummary>,
1612 document_chunks: Vec<MemDocumentChunk>,
1613 fail_on: Option<&'static str>,
1615 delay: Option<std::time::Duration>,
1618 marked_ids: Mutex<Vec<String>>,
1620 captured_retrieval_strategy: Mutex<Option<GraphRetrievalStrategy>>,
1625 }
1626
1627 impl MockMemoryBackend {
1628 fn with_fail_on(method: &'static str) -> Self {
1629 debug_assert!(
1630 KNOWN_FAIL_ON.contains(&method),
1631 "unknown fail_on method name: {method}"
1632 );
1633 Self {
1634 fail_on: Some(method),
1635 ..Default::default()
1636 }
1637 }
1638
1639 fn fail_err(method: &str) -> Box<dyn std::error::Error + Send + Sync> {
1640 format!("mock error in {method}").into()
1641 }
1642 }
1643
1644 impl ContextMemoryBackend for MockMemoryBackend {
1645 fn load_persona_facts<'a>(
1646 &'a self,
1647 _min_confidence: f64,
1648 ) -> std::pin::Pin<
1649 Box<
1650 dyn std::future::Future<
1651 Output = Result<
1652 Vec<MemPersonaFact>,
1653 Box<dyn std::error::Error + Send + Sync>,
1654 >,
1655 > + Send
1656 + 'a,
1657 >,
1658 > {
1659 let result = if self.fail_on == Some("load_persona_facts") {
1660 Err(Self::fail_err("load_persona_facts"))
1661 } else {
1662 Ok(self.persona_facts.clone())
1663 };
1664 let delay = self.delay;
1665 Box::pin(async move {
1666 if let Some(d) = delay {
1667 tokio::time::sleep(d).await;
1668 }
1669 result
1670 })
1671 }
1672
1673 fn load_trajectory_entries<'a>(
1674 &'a self,
1675 _tier: Option<&'a str>,
1676 _top_k: usize,
1677 ) -> std::pin::Pin<
1678 Box<
1679 dyn std::future::Future<
1680 Output = Result<
1681 Vec<MemTrajectoryEntry>,
1682 Box<dyn std::error::Error + Send + Sync>,
1683 >,
1684 > + Send
1685 + 'a,
1686 >,
1687 > {
1688 let result = if self.fail_on == Some("load_trajectory_entries") {
1689 Err(Self::fail_err("load_trajectory_entries"))
1690 } else {
1691 Ok(self.trajectory_entries.clone())
1692 };
1693 Box::pin(async move { result })
1694 }
1695
1696 fn load_tree_nodes<'a>(
1697 &'a self,
1698 _level: u32,
1699 _top_k: usize,
1700 ) -> std::pin::Pin<
1701 Box<
1702 dyn std::future::Future<
1703 Output = Result<Vec<MemTreeNode>, Box<dyn std::error::Error + Send + Sync>>,
1704 > + Send
1705 + 'a,
1706 >,
1707 > {
1708 let result = if self.fail_on == Some("load_tree_nodes") {
1709 Err(Self::fail_err("load_tree_nodes"))
1710 } else {
1711 Ok(self.tree_nodes.clone())
1712 };
1713 Box::pin(async move { result })
1714 }
1715
1716 fn load_summaries<'a>(
1717 &'a self,
1718 _conversation_id: i64,
1719 ) -> std::pin::Pin<
1720 Box<
1721 dyn std::future::Future<
1722 Output = Result<Vec<MemSummary>, Box<dyn std::error::Error + Send + Sync>>,
1723 > + Send
1724 + 'a,
1725 >,
1726 > {
1727 let result = if self.fail_on == Some("load_summaries") {
1728 Err(Self::fail_err("load_summaries"))
1729 } else {
1730 Ok(self.summaries.clone())
1731 };
1732 Box::pin(async move { result })
1733 }
1734
1735 fn retrieve_reasoning_strategies<'a>(
1736 &'a self,
1737 _query: &'a str,
1738 _top_k: usize,
1739 ) -> std::pin::Pin<
1740 Box<
1741 dyn std::future::Future<
1742 Output = Result<
1743 Vec<MemReasoningStrategy>,
1744 Box<dyn std::error::Error + Send + Sync>,
1745 >,
1746 > + Send
1747 + 'a,
1748 >,
1749 > {
1750 let result = if self.fail_on == Some("retrieve_reasoning_strategies") {
1751 Err(Self::fail_err("retrieve_reasoning_strategies"))
1752 } else {
1753 Ok(self.reasoning_strategies.clone())
1754 };
1755 Box::pin(async move { result })
1756 }
1757
1758 fn mark_reasoning_used<'a>(
1759 &'a self,
1760 ids: &'a [String],
1761 ) -> std::pin::Pin<
1762 Box<
1763 dyn std::future::Future<
1764 Output = Result<(), Box<dyn std::error::Error + Send + Sync>>,
1765 > + Send
1766 + 'a,
1767 >,
1768 > {
1769 if self.fail_on == Some("mark_reasoning_used") {
1770 return Box::pin(async move { Err(Self::fail_err("mark_reasoning_used")) });
1771 }
1772 let mut guard = self.marked_ids.lock().expect("marked_ids poisoned");
1773 guard.extend_from_slice(ids);
1774 Box::pin(async move { Ok(()) })
1775 }
1776
1777 fn retrieve_corrections<'a>(
1778 &'a self,
1779 _query: &'a str,
1780 _limit: usize,
1781 _min_score: f32,
1782 ) -> std::pin::Pin<
1783 Box<
1784 dyn std::future::Future<
1785 Output = Result<
1786 Vec<MemCorrection>,
1787 Box<dyn std::error::Error + Send + Sync>,
1788 >,
1789 > + Send
1790 + 'a,
1791 >,
1792 > {
1793 let result = if self.fail_on == Some("retrieve_corrections") {
1794 Err(Self::fail_err("retrieve_corrections"))
1795 } else {
1796 Ok(self.corrections.clone())
1797 };
1798 Box::pin(async move { result })
1799 }
1800
1801 fn recall<'a>(
1802 &'a self,
1803 _query: &'a str,
1804 _limit: usize,
1805 _router: Option<&'a dyn zeph_common::memory::AsyncMemoryRouter>,
1806 ) -> std::pin::Pin<
1807 Box<
1808 dyn std::future::Future<
1809 Output = Result<
1810 Vec<MemRecalledMessage>,
1811 Box<dyn std::error::Error + Send + Sync>,
1812 >,
1813 > + Send
1814 + 'a,
1815 >,
1816 > {
1817 let result = if self.fail_on == Some("recall") {
1818 Err(Self::fail_err("recall"))
1819 } else {
1820 Ok(self.recalled.clone())
1821 };
1822 let delay = self.delay;
1823 Box::pin(async move {
1824 if let Some(d) = delay {
1825 tokio::time::sleep(d).await;
1826 }
1827 result
1828 })
1829 }
1830
1831 fn recall_graph_facts<'a>(
1832 &'a self,
1833 _query: &'a str,
1834 params: GraphRecallParams<'a>,
1835 ) -> std::pin::Pin<
1836 Box<
1837 dyn std::future::Future<
1838 Output = Result<
1839 Vec<MemGraphFact>,
1840 Box<dyn std::error::Error + Send + Sync>,
1841 >,
1842 > + Send
1843 + 'a,
1844 >,
1845 > {
1846 *self.captured_retrieval_strategy.lock().unwrap() = Some(params.retrieval_strategy);
1847 let result = if self.fail_on == Some("recall_graph_facts") {
1848 Err(Self::fail_err("recall_graph_facts"))
1849 } else {
1850 Ok(self.graph_facts.clone())
1851 };
1852 Box::pin(async move { result })
1853 }
1854
1855 fn search_session_summaries<'a>(
1856 &'a self,
1857 _query: &'a str,
1858 _limit: usize,
1859 _current_conversation_id: Option<i64>,
1860 ) -> std::pin::Pin<
1861 Box<
1862 dyn std::future::Future<
1863 Output = Result<
1864 Vec<MemSessionSummary>,
1865 Box<dyn std::error::Error + Send + Sync>,
1866 >,
1867 > + Send
1868 + 'a,
1869 >,
1870 > {
1871 let result = if self.fail_on == Some("search_session_summaries") {
1872 Err(Self::fail_err("search_session_summaries"))
1873 } else {
1874 Ok(self.session_summaries.clone())
1875 };
1876 Box::pin(async move { result })
1877 }
1878
1879 fn search_document_collection<'a>(
1880 &'a self,
1881 _collection: &'a str,
1882 _query: &'a str,
1883 _top_k: usize,
1884 ) -> std::pin::Pin<
1885 Box<
1886 dyn std::future::Future<
1887 Output = Result<
1888 Vec<MemDocumentChunk>,
1889 Box<dyn std::error::Error + Send + Sync>,
1890 >,
1891 > + Send
1892 + 'a,
1893 >,
1894 > {
1895 let result = if self.fail_on == Some("search_document_collection") {
1896 Err(Self::fail_err("search_document_collection"))
1897 } else {
1898 Ok(self.document_chunks.clone())
1899 };
1900 Box::pin(async move { result })
1901 }
1902 }
1903
1904 fn mock_view(mock: MockMemoryBackend) -> ContextMemoryView {
1905 let mut v = empty_view();
1906 v.memory = Some(Arc::new(mock));
1907 v
1908 }
1909
1910 #[tokio::test]
1913 async fn fetch_graph_facts_returns_message_when_memory_present() {
1914 let mock = MockMemoryBackend {
1915 graph_facts: vec![zeph_common::memory::MemGraphFact {
1916 fact: "Rust is fast".to_string(),
1917 confidence: 0.9,
1918 activation_score: None,
1919 neighbors: vec![],
1920 provenance_snippet: None,
1921 }],
1922 ..Default::default()
1923 };
1924 let mut view = mock_view(mock);
1925 view.graph_config.enabled = true;
1926 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1928 let tc = NaiveTokenCounter;
1929 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1930 assert!(result.is_some(), "expected Some message");
1931 let msg = result.unwrap();
1932 assert!(
1933 msg.content.contains("Rust is fast"),
1934 "expected fact text in output, got: {}",
1935 msg.content
1936 );
1937 assert!(
1938 msg.content.starts_with(GRAPH_FACTS_PREFIX),
1939 "expected GRAPH_FACTS_PREFIX"
1940 );
1941 }
1942
1943 #[tokio::test]
1944 async fn fetch_graph_facts_swallows_error_and_returns_none() {
1945 let mock = MockMemoryBackend::with_fail_on("recall_graph_facts");
1946 let mut view = mock_view(mock);
1947 view.graph_config.enabled = true;
1948 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1949 let tc = NaiveTokenCounter;
1950 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1952 assert!(
1953 result.is_none(),
1954 "expected None when recall_graph_facts errors"
1955 );
1956 }
1957
1958 #[tokio::test]
1959 async fn fetch_graph_facts_returns_none_when_facts_empty() {
1960 let mock = MockMemoryBackend::default(); let mut view = mock_view(mock);
1962 view.graph_config.enabled = true;
1963 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1964 let tc = NaiveTokenCounter;
1965 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1966 assert!(result.is_none());
1967 }
1968
1969 #[tokio::test]
1975 async fn fetch_graph_facts_forces_synapse_when_spreading_activation_enabled_regardless_of_configured_strategy()
1976 {
1977 let mock = Arc::new(MockMemoryBackend::default());
1978 let mut view = empty_view();
1979 view.memory = Some(mock.clone());
1980 view.graph_config.enabled = true;
1981 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1982 view.graph_config.spreading_activation.enabled = true;
1983 view.graph_config.retrieval_strategy =
1986 zeph_config::memory::GraphRetrievalStrategy::BeamSearch;
1987 let tc = NaiveTokenCounter;
1988
1989 fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1990
1991 assert_eq!(
1992 *mock.captured_retrieval_strategy.lock().unwrap(),
1993 Some(GraphRetrievalStrategy::Synapse),
1994 "sa_config.enabled=true must override retrieval_strategy=BeamSearch to Synapse"
1995 );
1996 }
1997
1998 #[tokio::test]
2009 async fn fetch_graph_facts_default_config_resolves_to_synapse_not_bfs() {
2010 let mock = Arc::new(MockMemoryBackend::default());
2011 let mut view = empty_view();
2012 view.memory = Some(mock.clone());
2013 view.graph_config.enabled = true;
2014 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
2015 let tc = NaiveTokenCounter;
2018
2019 fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
2020
2021 assert_eq!(
2022 *mock.captured_retrieval_strategy.lock().unwrap(),
2023 Some(GraphRetrievalStrategy::Synapse),
2024 "fully-default GraphConfig must resolve to Synapse, not Bfs — a real behavior \
2025 change versus the pre-#6566 live path, flagged for reviewer sign-off"
2026 );
2027 }
2028
2029 #[tokio::test]
2032 async fn fetch_persona_facts_returns_message_when_persona_enabled() {
2033 let mock = MockMemoryBackend {
2034 persona_facts: vec![MemPersonaFact {
2035 category: "preference".to_string(),
2036 content: "prefers concise answers".to_string(),
2037 }],
2038 ..Default::default()
2039 };
2040 let mut view = mock_view(mock);
2041 view.persona_config.enabled = true;
2042 view.persona_config.context_budget_tokens = 1000;
2043 let tc = NaiveTokenCounter;
2044 let result = fetch_persona_facts(&view, 1000, &tc).await.unwrap();
2045 assert!(result.is_some());
2046 let msg = result.unwrap();
2047 assert!(msg.content.contains("preference"));
2048 assert!(msg.content.contains("prefers concise answers"));
2049 assert!(msg.content.starts_with(crate::slot::PERSONA_PREFIX));
2050 }
2051
2052 #[tokio::test]
2053 async fn fetch_persona_facts_propagates_error() {
2054 let mock = MockMemoryBackend::with_fail_on("load_persona_facts");
2055 let mut view = mock_view(mock);
2056 view.persona_config.enabled = true;
2057 let tc = NaiveTokenCounter;
2058 let result = fetch_persona_facts(&view, 1000, &tc).await;
2059 assert!(
2060 result.is_err(),
2061 "expected Err from load_persona_facts failure"
2062 );
2063 }
2064
2065 #[tokio::test]
2068 async fn fetch_trajectory_hints_returns_message_when_trajectory_enabled() {
2069 let mock = MockMemoryBackend {
2070 trajectory_entries: vec![MemTrajectoryEntry {
2071 intent: "summarize code".to_string(),
2072 outcome: "produced concise summary".to_string(),
2073 confidence: 0.9,
2074 }],
2075 ..Default::default()
2076 };
2077 let mut view = mock_view(mock);
2078 view.trajectory_config.enabled = true;
2079 view.trajectory_config.context_budget_tokens = 1000;
2080 view.trajectory_config.min_confidence = 0.5;
2081 let tc = NaiveTokenCounter;
2082 let result = fetch_trajectory_hints(&view, 1000, &tc).await.unwrap();
2083 assert!(result.is_some());
2084 let msg = result.unwrap();
2085 assert!(msg.content.contains("summarize code"));
2086 assert!(msg.content.starts_with(crate::slot::TRAJECTORY_PREFIX));
2087 }
2088
2089 #[tokio::test]
2090 async fn fetch_trajectory_hints_passes_tier_filter() {
2091 let mock = MockMemoryBackend {
2094 trajectory_entries: vec![
2095 MemTrajectoryEntry {
2096 intent: "debug async code".to_string(),
2097 outcome: "fixed deadlock".to_string(),
2098 confidence: 0.85,
2099 },
2100 MemTrajectoryEntry {
2101 intent: "low confidence task".to_string(),
2102 outcome: "irrelevant".to_string(),
2103 confidence: 0.3,
2104 },
2105 ],
2106 ..Default::default()
2107 };
2108 let mut view = mock_view(mock);
2109 view.trajectory_config.enabled = true;
2110 view.trajectory_config.context_budget_tokens = 1000;
2111 view.trajectory_config.min_confidence = 0.5;
2112 let tc = NaiveTokenCounter;
2113 let result = fetch_trajectory_hints(&view, 1000, &tc).await.unwrap();
2114 assert!(result.is_some(), "expected Some message");
2115 let msg = result.unwrap();
2116 assert!(
2117 msg.content.contains("debug async code"),
2118 "high-confidence entry must be included"
2119 );
2120 assert!(
2121 !msg.content.contains("low confidence task"),
2122 "entry below min_confidence must be filtered out"
2123 );
2124 }
2125
2126 #[tokio::test]
2127 async fn fetch_trajectory_hints_propagates_error() {
2128 let mock = MockMemoryBackend::with_fail_on("load_trajectory_entries");
2129 let mut view = mock_view(mock);
2130 view.trajectory_config.enabled = true;
2131 let tc = NaiveTokenCounter;
2132 let result = fetch_trajectory_hints(&view, 1000, &tc).await;
2133 assert!(result.is_err());
2134 }
2135
2136 #[tokio::test]
2139 async fn fetch_tree_memory_returns_message_when_tree_enabled() {
2140 let mock = MockMemoryBackend {
2141 tree_nodes: vec![MemTreeNode {
2142 content: "Topic: async Rust patterns".to_string(),
2143 }],
2144 ..Default::default()
2145 };
2146 let mut view = mock_view(mock);
2147 view.tree_config.enabled = true;
2148 view.tree_config.context_budget_tokens = 1000;
2149 let tc = NaiveTokenCounter;
2150 let result = fetch_tree_memory(&view, 1000, &tc).await.unwrap();
2151 assert!(result.is_some());
2152 let msg = result.unwrap();
2153 assert!(msg.content.contains("async Rust patterns"));
2154 assert!(msg.content.starts_with(crate::slot::TREE_MEMORY_PREFIX));
2155 }
2156
2157 #[tokio::test]
2158 async fn fetch_tree_memory_propagates_error() {
2159 let mock = MockMemoryBackend::with_fail_on("load_tree_nodes");
2160 let mut view = mock_view(mock);
2161 view.tree_config.enabled = true;
2162 let tc = NaiveTokenCounter;
2163 let result = fetch_tree_memory(&view, 1000, &tc).await;
2164 assert!(result.is_err());
2165 }
2166
2167 #[tokio::test]
2170 async fn fetch_corrections_returns_message_when_corrections_present() {
2171 let mock = MockMemoryBackend {
2172 corrections: vec![MemCorrection {
2173 correction_text: "use snake_case not camelCase".to_string(),
2174 }],
2175 ..Default::default()
2176 };
2177 let view = mock_view(mock);
2178 let result = fetch_corrections(&view, "query", 10, 0.5, |s| s.into())
2179 .await
2180 .unwrap();
2181 assert!(result.is_some());
2182 let msg = result.unwrap();
2183 assert!(msg.content.contains("snake_case"));
2184 assert!(msg.content.starts_with(CORRECTIONS_PREFIX));
2185 }
2186
2187 #[tokio::test]
2188 async fn fetch_corrections_propagates_error() {
2189 let mock = MockMemoryBackend::with_fail_on("retrieve_corrections");
2192 let view = mock_view(mock);
2193 let result = fetch_corrections(&view, "query", 10, 0.5, |s| s.into()).await;
2194 assert!(result.is_err(), "expected Err, got {result:?}");
2195 }
2196
2197 #[tokio::test]
2200 async fn fetch_semantic_recall_returns_message_with_content() {
2201 let mock = MockMemoryBackend {
2202 recalled: vec![
2203 MemRecalledMessage {
2204 role: "user".to_string(),
2205 content: "how does tokio work".to_string(),
2206 score: 0.95,
2207 },
2208 MemRecalledMessage {
2209 role: "assistant".to_string(),
2210 content: "tokio is an async runtime".to_string(),
2211 score: 0.88,
2212 },
2213 ],
2214 ..Default::default()
2215 };
2216 let mut view = mock_view(mock);
2217 view.recall_limit = 10;
2218 let tc = NaiveTokenCounter;
2219 let (msg, score) = fetch_semantic_recall(&view, "tokio", 1000, &tc, None)
2220 .await
2221 .unwrap();
2222 assert!(msg.is_some(), "expected Some message");
2223 assert!(score.is_some_and(|s| (s - 0.95_f32).abs() < f32::EPSILON));
2225 let msg = msg.unwrap();
2226 let has_recall_part = msg.parts.iter().any(|p| {
2228 if let zeph_llm::provider::MessagePart::Recall { text } = p {
2229 text.contains("how does tokio work")
2230 } else {
2231 false
2232 }
2233 });
2234 assert!(has_recall_part, "expected recalled content in Recall part");
2235 }
2236
2237 #[tokio::test]
2238 async fn fetch_semantic_recall_returns_none_when_recalled_empty() {
2239 let mock = MockMemoryBackend::default();
2240 let mut view = mock_view(mock);
2241 view.recall_limit = 10;
2242 let tc = NaiveTokenCounter;
2243 let (msg, score) = fetch_semantic_recall(&view, "query", 1000, &tc, None)
2244 .await
2245 .unwrap();
2246 assert!(msg.is_none());
2247 assert!(score.is_none());
2248 }
2249
2250 #[tokio::test]
2251 async fn fetch_semantic_recall_propagates_error() {
2252 let mock = MockMemoryBackend::with_fail_on("recall");
2253 let mut view = mock_view(mock);
2254 view.recall_limit = 10;
2255 let tc = NaiveTokenCounter;
2256 let result = fetch_semantic_recall(&view, "query", 1000, &tc, None).await;
2257 assert!(result.is_err());
2258 }
2259
2260 #[tokio::test]
2263 async fn fetch_document_rag_returns_message_when_rag_enabled() {
2264 let mock = MockMemoryBackend {
2265 document_chunks: vec![MemDocumentChunk {
2266 text: "Rust ownership rules prevent data races".to_string(),
2267 }],
2268 ..Default::default()
2269 };
2270 let mut view = mock_view(mock);
2271 view.document_config.rag_enabled = true;
2272 let tc = NaiveTokenCounter;
2273 let result = fetch_document_rag(&view, "ownership", 1000, &tc)
2274 .await
2275 .unwrap();
2276 assert!(result.is_some());
2277 let msg = result.unwrap();
2278 assert!(msg.content.contains("ownership rules"));
2279 assert!(msg.content.starts_with(DOCUMENT_RAG_PREFIX));
2280 }
2281
2282 #[tokio::test]
2283 async fn fetch_document_rag_propagates_error() {
2284 let mock = MockMemoryBackend::with_fail_on("search_document_collection");
2285 let mut view = mock_view(mock);
2286 view.document_config.rag_enabled = true;
2287 let tc = NaiveTokenCounter;
2288 let result = fetch_document_rag(&view, "query", 1000, &tc).await;
2289 assert!(result.is_err());
2290 }
2291
2292 #[tokio::test]
2295 async fn fetch_summaries_returns_message_when_summaries_present() {
2296 let mock = MockMemoryBackend {
2297 summaries: vec![MemSummary {
2298 first_message_id: Some(1),
2299 last_message_id: Some(5),
2300 content: "User asked about async Rust".to_string(),
2301 }],
2302 ..Default::default()
2303 };
2304 let mut view = mock_view(mock);
2305 view.conversation_id = Some(42);
2306 let tc = NaiveTokenCounter;
2307 let result = fetch_summaries(&view, 1000, &tc).await.unwrap();
2308 assert!(result.is_some());
2309 let msg = result.unwrap();
2310 let has_summary_part = msg.parts.iter().any(|p| {
2311 if let zeph_llm::provider::MessagePart::Summary { text } = p {
2312 text.contains("Messages 1-5") && text.contains("async Rust")
2313 } else {
2314 false
2315 }
2316 });
2317 assert!(
2318 has_summary_part,
2319 "expected Summary part with messages range"
2320 );
2321 }
2322
2323 #[tokio::test]
2324 async fn fetch_summaries_returns_none_without_conversation_id() {
2325 let mock = MockMemoryBackend {
2326 summaries: vec![MemSummary {
2327 first_message_id: Some(1),
2328 last_message_id: Some(5),
2329 content: "some content".to_string(),
2330 }],
2331 ..Default::default()
2332 };
2333 let mut view = mock_view(mock);
2334 view.conversation_id = None; let tc = NaiveTokenCounter;
2336 let result = fetch_summaries(&view, 1000, &tc).await.unwrap();
2337 assert!(result.is_none());
2338 }
2339
2340 #[tokio::test]
2341 async fn fetch_summaries_propagates_error() {
2342 let mock = MockMemoryBackend::with_fail_on("load_summaries");
2343 let mut view = mock_view(mock);
2344 view.conversation_id = Some(42);
2345 let tc = NaiveTokenCounter;
2346 let result = fetch_summaries(&view, 1000, &tc).await;
2347 assert!(result.is_err());
2348 }
2349
2350 #[tokio::test]
2353 async fn fetch_cross_session_returns_message_when_results_present() {
2354 let mock = MockMemoryBackend {
2355 session_summaries: vec![MemSessionSummary {
2356 summary_text: "Previous session: debugging tokio deadlock".to_string(),
2357 score: 0.9,
2358 }],
2359 ..Default::default()
2360 };
2361 let mut view = mock_view(mock);
2362 view.conversation_id = Some(1);
2363 view.cross_session_score_threshold = 0.5;
2364 let tc = NaiveTokenCounter;
2365 let result = fetch_cross_session(&view, "async", 1000, &tc)
2366 .await
2367 .unwrap();
2368 assert!(result.is_some());
2369 let msg = result.unwrap();
2370 let has_cross_session_part = msg.parts.iter().any(|p| {
2371 if let zeph_llm::provider::MessagePart::CrossSession { text } = p {
2372 text.contains("tokio deadlock")
2373 } else {
2374 false
2375 }
2376 });
2377 assert!(has_cross_session_part);
2378 }
2379
2380 #[tokio::test]
2381 async fn fetch_cross_session_propagates_error() {
2382 let mock = MockMemoryBackend::with_fail_on("search_session_summaries");
2383 let mut view = mock_view(mock);
2384 view.conversation_id = Some(1);
2385 let tc = NaiveTokenCounter;
2386 let result = fetch_cross_session(&view, "query", 1000, &tc).await;
2387 assert!(result.is_err());
2388 }
2389
2390 #[tokio::test]
2393 async fn fetch_reasoning_strategies_returns_message_and_marks_used() {
2394 let mock = Arc::new(MockMemoryBackend {
2395 reasoning_strategies: vec![
2396 MemReasoningStrategy {
2397 id: "strat-1".to_string(),
2398 outcome: "success".to_string(),
2399 summary: "break the problem into small steps".to_string(),
2400 },
2401 MemReasoningStrategy {
2402 id: "strat-2".to_string(),
2403 outcome: "success".to_string(),
2404 summary: "use tracing spans for debugging".to_string(),
2405 },
2406 ],
2407 ..Default::default()
2408 });
2409 let marked_ids = Arc::clone(&mock);
2410 let mut view = empty_view();
2411 view.memory = Some(mock);
2412 view.reasoning_config.enabled = true;
2413 view.reasoning_config.context_budget_tokens = 1000;
2414 let tc = NaiveTokenCounter;
2415 let (result, handle) = fetch_reasoning_strategies(&view, "debug", 1000, 5, &tc)
2416 .await
2417 .unwrap();
2418 assert!(result.is_some());
2419 let msg = result.unwrap();
2420 assert!(msg.content.starts_with(crate::slot::REASONING_PREFIX));
2421 assert!(msg.content.contains("break the problem"));
2422
2423 if let Some(h) = handle {
2425 h.await.expect("mark_reasoning_used task panicked");
2426 }
2427
2428 let ids = marked_ids.marked_ids.lock().expect("marked_ids poisoned");
2429 assert!(
2430 ids.contains(&"strat-1".to_string()),
2431 "expected strat-1 marked"
2432 );
2433 assert!(
2434 ids.contains(&"strat-2".to_string()),
2435 "expected strat-2 marked"
2436 );
2437 }
2438
2439 #[tokio::test]
2440 async fn fetch_reasoning_strategies_propagates_error() {
2441 let mock = MockMemoryBackend::with_fail_on("retrieve_reasoning_strategies");
2442 let mut view = mock_view(mock);
2443 view.reasoning_config.enabled = true;
2444 let tc = NaiveTokenCounter;
2445 let result = fetch_reasoning_strategies(&view, "query", 1000, 3, &tc).await;
2446 assert!(result.is_err());
2447 }
2448
2449 #[tokio::test]
2452 async fn fetch_semantic_recall_skips_skipped_and_stopped_messages() {
2453 let mock = MockMemoryBackend {
2454 recalled: vec![
2455 MemRecalledMessage {
2456 role: "user".to_string(),
2457 content: "[skipped] some content".to_string(),
2458 score: 0.95,
2459 },
2460 MemRecalledMessage {
2461 role: "user".to_string(),
2462 content: "[stopped] other content".to_string(),
2463 score: 0.90,
2464 },
2465 MemRecalledMessage {
2466 role: "user".to_string(),
2467 content: "valid content to recall".to_string(),
2468 score: 0.85,
2469 },
2470 ],
2471 ..Default::default()
2472 };
2473 let mut view = mock_view(mock);
2474 view.recall_limit = 10;
2475 let tc = NaiveTokenCounter;
2476 let (msg, _) = fetch_semantic_recall(&view, "query", 1000, &tc, None)
2477 .await
2478 .unwrap();
2479 assert!(msg.is_some());
2480 let msg = msg.unwrap();
2481 let full_text = msg.parts.iter().find_map(|p| {
2482 if let zeph_llm::provider::MessagePart::Recall { text } = p {
2483 Some(text.clone())
2484 } else {
2485 None
2486 }
2487 });
2488 let text = full_text.unwrap_or_default();
2489 assert!(
2490 !text.contains("[skipped]"),
2491 "skipped messages must be excluded"
2492 );
2493 assert!(
2494 !text.contains("[stopped]"),
2495 "stopped messages must be excluded"
2496 );
2497 assert!(
2498 text.contains("valid content to recall"),
2499 "valid messages must be included"
2500 );
2501 }
2502
2503 #[tokio::test]
2504 async fn fetch_cross_session_filters_below_threshold() {
2505 let mock = MockMemoryBackend {
2506 session_summaries: vec![
2507 MemSessionSummary {
2508 summary_text: "high relevance session".to_string(),
2509 score: 0.9,
2510 },
2511 MemSessionSummary {
2512 summary_text: "low relevance session".to_string(),
2513 score: 0.2,
2514 },
2515 ],
2516 ..Default::default()
2517 };
2518 let mut view = mock_view(mock);
2519 view.conversation_id = Some(1);
2520 view.cross_session_score_threshold = 0.5;
2521 let tc = NaiveTokenCounter;
2522 let result = fetch_cross_session(&view, "query", 1000, &tc)
2523 .await
2524 .unwrap();
2525 assert!(result.is_some());
2526 let msg = result.unwrap();
2527 let text = msg
2528 .parts
2529 .iter()
2530 .find_map(|p| {
2531 if let zeph_llm::provider::MessagePart::CrossSession { text } = p {
2532 Some(text.clone())
2533 } else {
2534 None
2535 }
2536 })
2537 .unwrap_or_default();
2538 assert!(
2539 text.contains("high relevance"),
2540 "high score must be included"
2541 );
2542 assert!(
2543 !text.contains("low relevance"),
2544 "low score must be filtered out"
2545 );
2546 }
2547
2548 #[tokio::test]
2549 async fn fetch_document_rag_skips_empty_chunks() {
2550 let mock = MockMemoryBackend {
2551 document_chunks: vec![
2552 MemDocumentChunk {
2553 text: String::new(),
2554 }, MemDocumentChunk {
2556 text: "real content here".to_string(),
2557 },
2558 ],
2559 ..Default::default()
2560 };
2561 let mut view = mock_view(mock);
2562 view.document_config.rag_enabled = true;
2563 let tc = NaiveTokenCounter;
2564 let result = fetch_document_rag(&view, "query", 1000, &tc).await.unwrap();
2565 assert!(result.is_some());
2566 let msg = result.unwrap();
2567 assert!(msg.content.contains("real content here"));
2568 assert!(!msg.content.contains("\n\n\n"));
2570 }
2571
2572 #[tokio::test]
2573 async fn fetch_graph_facts_sanitizes_injection_payloads() {
2574 let mock = MockMemoryBackend {
2576 graph_facts: vec![zeph_common::memory::MemGraphFact {
2577 fact: "fact with <script>alert(1)</script> and\nnewline".to_string(),
2578 confidence: 0.8,
2579 activation_score: None,
2580 neighbors: vec![],
2581 provenance_snippet: None,
2582 }],
2583 ..Default::default()
2584 };
2585 let mut view = mock_view(mock);
2586 view.graph_config.enabled = true;
2587 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
2588 let tc = NaiveTokenCounter;
2589 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
2590 assert!(result.is_some());
2591 let msg = result.unwrap();
2592 assert!(
2593 !msg.content.contains('<'),
2594 "angle brackets must be sanitized"
2595 );
2596 assert!(
2599 !msg.content.contains("\n\n"),
2600 "embedded newlines must be sanitized, no double-newline sequences expected"
2601 );
2602 }
2603
2604 #[tokio::test]
2605 async fn fetch_reasoning_strategies_sanitizes_injection_payloads() {
2606 let mock = MockMemoryBackend {
2608 reasoning_strategies: vec![MemReasoningStrategy {
2609 id: "s1".to_string(),
2610 outcome: "success".to_string(),
2611 summary: "strategy with <b>bold</b> and\nnewline".to_string(),
2612 }],
2613 ..Default::default()
2614 };
2615 let mut view = mock_view(mock);
2616 view.reasoning_config.enabled = true;
2617 let tc = NaiveTokenCounter;
2618 let (result, _handle) = fetch_reasoning_strategies(&view, "query", 1000, 3, &tc)
2619 .await
2620 .unwrap();
2621 assert!(result.is_some());
2622 let msg = result.unwrap();
2623 assert!(
2624 !msg.content.contains('<'),
2625 "angle brackets must be sanitized in strategy summaries"
2626 );
2627 }
2628
2629 #[tokio::test]
2632 async fn fetch_persona_facts_truncates_at_budget() {
2633 let tc = NaiveTokenCounter;
2634 let first_line = "[pref] brief\n";
2636 let budget = tc.count_tokens(crate::slot::PERSONA_PREFIX) + tc.count_tokens(first_line);
2637 let mock = MockMemoryBackend {
2638 persona_facts: vec![
2639 MemPersonaFact {
2640 category: "pref".to_string(),
2641 content: "brief".to_string(),
2642 },
2643 MemPersonaFact {
2644 category: "lang".to_string(),
2645 content: "english".to_string(),
2646 },
2647 ],
2648 ..Default::default()
2649 };
2650 let mut view = mock_view(mock);
2651 view.persona_config.enabled = true;
2652 let result = fetch_persona_facts(&view, budget, &tc).await.unwrap();
2653 let msg = result.unwrap();
2654 assert!(msg.content.contains("brief"), "first fact must be included");
2655 assert!(
2656 !msg.content.contains("english"),
2657 "second fact must be truncated by budget"
2658 );
2659 }
2660
2661 #[tokio::test]
2662 async fn fetch_semantic_recall_truncates_at_budget() {
2663 let tc = NaiveTokenCounter;
2664 let first_entry = "- [user] first message\n";
2666 let budget = tc.count_tokens(RECALL_PREFIX) + tc.count_tokens(first_entry);
2667 let mock = MockMemoryBackend {
2668 recalled: vec![
2669 MemRecalledMessage {
2670 role: "user".to_string(),
2671 content: "first message".to_string(),
2672 score: 0.95,
2673 },
2674 MemRecalledMessage {
2675 role: "user".to_string(),
2676 content: "second message that should be truncated".to_string(),
2677 score: 0.80,
2678 },
2679 ],
2680 ..Default::default()
2681 };
2682 let mut view = mock_view(mock);
2683 view.recall_limit = 10;
2684 let (msg, _) = fetch_semantic_recall(&view, "query", budget, &tc, None)
2685 .await
2686 .unwrap();
2687 assert!(msg.is_some());
2688 let text = msg
2689 .unwrap()
2690 .parts
2691 .iter()
2692 .find_map(|p| {
2693 if let zeph_llm::provider::MessagePart::Recall { text } = p {
2694 Some(text.clone())
2695 } else {
2696 None
2697 }
2698 })
2699 .unwrap_or_default();
2700 assert!(
2701 text.contains("first message"),
2702 "first entry must be included"
2703 );
2704 assert!(
2705 !text.contains("second message"),
2706 "second entry must be truncated by budget"
2707 );
2708 }
2709
2710 #[tokio::test]
2713 async fn fetch_graph_facts_sanitizes_provenance_snippet() {
2714 use zeph_common::memory::MemGraphNeighbor;
2715 let mock = MockMemoryBackend {
2716 graph_facts: vec![zeph_common::memory::MemGraphFact {
2717 fact: "safe fact".to_string(),
2718 confidence: 0.9,
2719 activation_score: None,
2720 neighbors: vec![MemGraphNeighbor {
2721 fact: "neighbor".to_string(),
2722 confidence: 0.7,
2723 }],
2724 provenance_snippet: Some("source with <injection>\nand newline".to_string()),
2725 }],
2726 ..Default::default()
2727 };
2728 let mut view = mock_view(mock);
2729 view.graph_config.enabled = true;
2730 view.graph_config.spreading_activation.recall_timeout_ms = 5000;
2731 let tc = NaiveTokenCounter;
2732 let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
2733 assert!(result.is_some());
2734 let msg = result.unwrap();
2735 assert!(
2736 !msg.content.contains('<'),
2737 "angle brackets in provenance_snippet must be sanitized"
2738 );
2739 assert!(
2740 !msg.content.contains("\n\n"),
2741 "newlines in provenance_snippet must be sanitized"
2742 );
2743 assert!(
2744 msg.content.contains("[source:"),
2745 "provenance snippet must be rendered"
2746 );
2747 }
2748
2749 #[tokio::test(start_paused = true)]
2757 async fn fetch_persona_facts_degrades_to_empty_on_timeout() {
2758 let mock = MockMemoryBackend {
2759 persona_facts: vec![MemPersonaFact {
2760 category: "pref".to_string(),
2761 content: "would have been returned".to_string(),
2762 }],
2763 delay: Some(std::time::Duration::from_millis(
2764 MEMORY_FETCH_TIMEOUT_MS + 1000,
2765 )),
2766 ..Default::default()
2767 };
2768 let mut view = mock_view(mock);
2769 view.persona_config.enabled = true;
2770 let tc = NaiveTokenCounter;
2771 let result = fetch_persona_facts(&view, 1000, &tc).await;
2772 assert!(
2773 result.is_ok(),
2774 "timeout must degrade gracefully, not propagate as an error: {result:?}"
2775 );
2776 assert!(
2777 result.unwrap().is_none(),
2778 "timed-out fetch must yield no message, not the stale backend data"
2779 );
2780 }
2781
2782 #[tokio::test(start_paused = true)]
2783 async fn fetch_semantic_recall_degrades_to_empty_on_timeout() {
2784 let mock = MockMemoryBackend {
2785 recalled: vec![MemRecalledMessage {
2786 role: "user".to_string(),
2787 content: "would have been returned".to_string(),
2788 score: 0.95,
2789 }],
2790 delay: Some(std::time::Duration::from_millis(
2791 MEMORY_FETCH_TIMEOUT_MS + 1000,
2792 )),
2793 ..Default::default()
2794 };
2795 let mut view = mock_view(mock);
2796 view.recall_limit = 10;
2797 let tc = NaiveTokenCounter;
2798 let result = fetch_semantic_recall(&view, "query", 1000, &tc, None).await;
2799 assert!(
2800 result.is_ok(),
2801 "timeout must degrade gracefully, not propagate as an error: {result:?}"
2802 );
2803 let (msg, score) = result.unwrap();
2804 assert!(msg.is_none(), "timed-out recall must yield no message");
2805 assert!(score.is_none(), "timed-out recall must yield no score");
2806 }
2807
2808 #[test]
2811 fn append_budgeted_lines_empty_input_returns_none() {
2812 let tc = NaiveTokenCounter;
2813 let result = append_budgeted_lines("prefix\n", std::iter::empty(), 1000, &tc);
2814 assert!(result.is_none());
2815 }
2816
2817 #[test]
2818 fn append_budgeted_lines_all_items_fit() {
2819 let tc = NaiveTokenCounter;
2820 let lines = vec![
2821 "one\n".to_string(),
2822 "two\n".to_string(),
2823 "three\n".to_string(),
2824 ];
2825 let result = append_budgeted_lines("prefix\n", lines.into_iter(), 1000, &tc).unwrap();
2826 assert!(result.starts_with("prefix\n"));
2827 assert!(result.contains("one"));
2828 assert!(result.contains("two"));
2829 assert!(result.contains("three"));
2830 }
2831
2832 #[test]
2833 fn append_budgeted_lines_truncates_at_budget() {
2834 let tc = NaiveTokenCounter;
2835 let prefix = "prefix\n";
2836 let first = "one\n";
2837 let budget = tc.count_tokens(prefix) + tc.count_tokens(first);
2839 let lines = vec![first.to_string(), "two extra words here\n".to_string()];
2840 let result = append_budgeted_lines(prefix, lines.into_iter(), budget, &tc).unwrap();
2841 assert!(result.contains("one"), "first line must fit in budget");
2842 assert!(
2843 !result.contains("two extra words"),
2844 "second line must be truncated by budget"
2845 );
2846 }
2847
2848 #[test]
2849 fn append_budgeted_lines_zero_budget_returns_none() {
2850 let tc = NaiveTokenCounter;
2851 let lines = vec!["one\n".to_string()];
2852 let result = append_budgeted_lines("prefix\n", lines.into_iter(), 0, &tc);
2853 assert!(result.is_none(), "no line can fit within a zero budget");
2854 }
2855}