1use std::fmt::Write as _;
14use std::future::Future;
15use std::time::Instant;
16
17use zeph_config::ContextFormat;
18use zeph_llm::provider::{Message, MessagePart, Role};
19use zeph_memory::{RetrievalFailureRecord, RetrievalFailureType, TokenCounter};
20
21use crate::error::ContextError;
22use crate::state::ContextAssemblyView;
23
24pub const PERSONA_PREFIX: &str = "[Persona context]\n";
26pub const TRAJECTORY_PREFIX: &str = "[Past experience]\n";
28pub const TREE_MEMORY_PREFIX: &str = "[Memory summary]\n";
30pub const REASONING_PREFIX: &str = "[Reasoning Strategy]\n";
32
33pub const GRAPH_FACTS_PREFIX: &str = "[known facts]\n";
35pub const RECALL_PREFIX: &str = "[semantic recall]\n";
37pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n";
39pub const CROSS_SESSION_PREFIX: &str = "[cross-session context]\n";
41
42pub const CORRECTIONS_PREFIX: &str = "[past corrections]\n";
44pub const CODE_CONTEXT_PREFIX: &str = "[code context]\n";
46pub const SESSION_DIGEST_PREFIX: &str = "[Session digest from previous interaction]\n";
48pub const LSP_NOTE_PREFIX: &str = "[lsp ";
50pub const DOCUMENT_RAG_PREFIX: &str = "## Relevant documents\n";
52
53#[must_use]
57pub fn truncate_chars(s: &str, max_chars: usize) -> String {
58 zeph_common::text::truncate_to_chars(s, max_chars)
59}
60
61#[must_use]
66pub fn format_correction_note(correction_text: &str) -> String {
67 format!(
68 "- Past user correction: \"{}\"",
69 truncate_chars(correction_text, 200)
70 )
71}
72
73pub fn effective_recall_timeout_ms(configured: u64) -> u64 {
78 if configured == 0 {
79 tracing::warn!(
80 "recall_timeout_ms is 0, which would disable spreading activation recall; \
81 clamping to 100ms"
82 );
83 100
84 } else {
85 configured
86 }
87}
88
89#[tracing::instrument(name = "agent_context.helpers.fetch_graph_facts", skip_all, err)]
100pub async fn fetch_graph_facts(
101 view: &ContextAssemblyView<'_>,
102 query: &str,
103 budget_tokens: usize,
104 tc: &TokenCounter,
105) -> Result<Option<Message>, ContextError> {
106 fetch_graph_facts_raw(
107 view.memory.as_deref(),
108 &view.graph_config,
109 query,
110 budget_tokens,
111 tc,
112 )
113 .await
114 .map_err(ContextError::Memory)
115}
116
117fn append_graph_facts(
119 facts: &[zeph_memory::graph::types::GraphFact],
120 body: &mut String,
121 tokens_so_far: &mut usize,
122 budget_tokens: usize,
123 tc: &TokenCounter,
124) -> usize {
125 let mut count = 0;
126 for f in facts {
127 let fact_text = f.fact.replace(['\n', '\r', '<', '>'], " ");
128 let line = format!("- {} (confidence: {:.2})\n", fact_text, f.confidence);
129 let line_tokens = tc.count_tokens(&line);
130 if *tokens_so_far + line_tokens > budget_tokens {
131 break;
132 }
133 body.push_str(&line);
134 *tokens_so_far += line_tokens;
135 count += 1;
136 }
137 count
138}
139
140#[allow(clippy::too_many_arguments)]
160async fn run_graph_strategy<F>(
161 memory: &zeph_memory::semantic::SemanticMemory,
162 strategy_str: &str,
163 query: &str,
164 edge_types_json: Option<String>,
165 start: Instant,
166 recall: F,
167 body: &mut String,
168 tokens_so_far: &mut usize,
169 budget_tokens: usize,
170 tc: &TokenCounter,
171) -> Result<(), zeph_memory::MemoryError>
172where
173 F: Future<Output = Result<Vec<zeph_memory::graph::types::GraphFact>, zeph_memory::MemoryError>>,
174{
175 let facts = match recall.await {
176 Ok(f) => f,
177 Err(e) => {
178 let latency_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
179 memory.log_retrieval_failure(RetrievalFailureRecord {
180 conversation_id: None,
181 turn_index: 0,
182 failure_type: RetrievalFailureType::Error,
183 retrieval_strategy: strategy_str.to_owned(),
184 query_text: query.to_owned(),
185 query_len: query.len(),
186 top_score: None,
187 confidence_threshold: None,
188 result_count: 0,
189 latency_ms,
190 edge_types: edge_types_json,
191 error_context: Some(format!("{e:#}")),
192 });
193 return Err(e);
194 }
195 };
196 let latency_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
197 if facts.is_empty() {
198 memory.log_retrieval_failure(RetrievalFailureRecord {
199 conversation_id: None,
200 turn_index: 0,
201 failure_type: RetrievalFailureType::NoHit,
202 retrieval_strategy: strategy_str.to_owned(),
203 query_text: query.to_owned(),
204 query_len: query.len(),
205 top_score: None,
206 confidence_threshold: None,
207 result_count: 0,
208 latency_ms,
209 edge_types: edge_types_json,
210 error_context: None,
211 });
212 return Ok(());
213 }
214 append_graph_facts(&facts, body, tokens_so_far, budget_tokens, tc);
215 Ok(())
216}
217
218#[allow(clippy::too_many_arguments)]
229async fn run_synapse_strategy(
230 memory: &zeph_memory::semantic::SemanticMemory,
231 sa_config: &zeph_config::memory::SpreadingActivationConfig,
232 strategy_str: &str,
233 query: &str,
234 recall_limit: usize,
235 temporal_decay_rate: f64,
236 edge_types: &[zeph_memory::graph::EdgeType],
237 edge_types_json: Option<String>,
238 body: &mut String,
239 tokens_so_far: &mut usize,
240 budget_tokens: usize,
241 tc: &TokenCounter,
242) {
243 let sa_params = zeph_memory::graph::SpreadingActivationParams {
244 decay_lambda: sa_config.decay_lambda,
245 max_hops: sa_config.max_hops,
246 activation_threshold: sa_config.activation_threshold,
247 inhibition_threshold: sa_config.inhibition_threshold,
248 max_activated_nodes: sa_config.max_activated_nodes,
249 temporal_decay_rate,
250 seed_structural_weight: sa_config.seed_structural_weight,
251 seed_community_cap: sa_config.seed_community_cap,
252 alpha: sa_config.alpha,
253 };
254 let timeout_ms = effective_recall_timeout_ms(sa_config.recall_timeout_ms);
255 let t0 = Instant::now();
256 let activated_facts = match tokio::time::timeout(
257 std::time::Duration::from_millis(timeout_ms),
258 memory.recall_graph_activated(query, recall_limit, sa_params, edge_types),
259 )
260 .await
261 {
262 Ok(Ok(facts)) => facts,
263 Ok(Err(e)) => {
264 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
265 tracing::warn!("spreading activation recall failed: {e:#}");
266 memory.log_retrieval_failure(RetrievalFailureRecord {
270 conversation_id: None,
271 turn_index: 0,
272 failure_type: RetrievalFailureType::Error,
273 retrieval_strategy: strategy_str.to_owned(),
274 query_text: query.to_owned(),
275 query_len: query.len(),
276 top_score: None,
277 confidence_threshold: None,
278 result_count: 0,
279 latency_ms,
280 edge_types: edge_types_json.clone(),
281 error_context: Some(format!("{e:#}")),
282 });
283 Vec::new()
284 }
285 Err(_) => {
286 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
287 tracing::warn!("spreading activation recall timed out ({timeout_ms}ms)");
288 memory.log_retrieval_failure(RetrievalFailureRecord {
289 conversation_id: None,
290 turn_index: 0,
291 failure_type: RetrievalFailureType::Timeout,
292 retrieval_strategy: strategy_str.to_owned(),
293 query_text: query.to_owned(),
294 query_len: query.len(),
295 top_score: None,
296 confidence_threshold: None,
297 result_count: 0,
298 latency_ms,
299 edge_types: edge_types_json.clone(),
300 error_context: Some(format!("timeout after {timeout_ms}ms")),
301 });
302 Vec::new()
303 }
304 };
305 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
306 if activated_facts.is_empty() {
307 memory.log_retrieval_failure(RetrievalFailureRecord {
308 conversation_id: None,
309 turn_index: 0,
310 failure_type: RetrievalFailureType::NoHit,
311 retrieval_strategy: strategy_str.to_owned(),
312 query_text: query.to_owned(),
313 query_len: query.len(),
314 top_score: None,
315 confidence_threshold: None,
316 result_count: 0,
317 latency_ms,
318 edge_types: edge_types_json,
319 error_context: None,
320 });
321 return;
322 }
323 for f in &activated_facts {
324 let fact_text = f.edge.fact.replace(['\n', '\r', '<', '>'], " ");
325 let line = format!(
326 "- {} (confidence: {:.2}, activation: {:.2})\n",
327 fact_text, f.edge.confidence, f.activation_score
328 );
329 let line_tokens = tc.count_tokens(&line);
330 if *tokens_so_far + line_tokens > budget_tokens {
331 break;
332 }
333 body.push_str(&line);
334 *tokens_so_far += line_tokens;
335 }
336}
337
338async fn classify_hybrid_strategy(
344 memory: &zeph_memory::semantic::SemanticMemory,
345 query: &str,
346 edge_types_json: Option<String>,
347) -> String {
348 const CLASSIFIER_TIMEOUT_MS: u64 = 2_000;
349 let classifier_t0 = Instant::now();
350 let classified = if let Ok(s) = tokio::time::timeout(
351 std::time::Duration::from_millis(CLASSIFIER_TIMEOUT_MS),
352 memory.classify_graph_strategy(query),
353 )
354 .await
355 {
356 s
357 } else {
358 let latency_ms = classifier_t0
359 .elapsed()
360 .as_millis()
361 .try_into()
362 .unwrap_or(u64::MAX);
363 tracing::warn!(
364 "hybrid strategy classifier timed out after {CLASSIFIER_TIMEOUT_MS}ms, \
365 falling back to synapse"
366 );
367 memory.log_retrieval_failure(RetrievalFailureRecord {
368 conversation_id: None,
369 turn_index: 0,
370 failure_type: RetrievalFailureType::Timeout,
371 retrieval_strategy: "hybrid_classifier".to_owned(),
372 query_text: query.to_owned(),
373 query_len: query.len(),
374 top_score: None,
375 confidence_threshold: None,
376 result_count: 0,
377 latency_ms,
378 edge_types: edge_types_json,
379 error_context: Some(format!(
380 "classifier timeout after {CLASSIFIER_TIMEOUT_MS}ms"
381 )),
382 });
383 "synapse".to_owned()
384 };
385 tracing::debug!(classified_strategy = %classified, "hybrid dispatch: classified");
386 classified
387}
388
389#[allow(clippy::too_many_arguments)]
396async fn recall_by_classified_strategy(
397 classified: &str,
398 memory: &zeph_memory::semantic::SemanticMemory,
399 graph_config: &zeph_config::GraphConfig,
400 sa_config: &zeph_config::memory::SpreadingActivationConfig,
401 query: &str,
402 recall_limit: usize,
403 max_hops: u32,
404 temporal_decay_rate: f64,
405 edge_types: &[zeph_memory::graph::EdgeType],
406) -> Result<Vec<zeph_memory::graph::types::GraphFact>, zeph_memory::MemoryError> {
407 match classified {
408 "astar" => {
409 memory
410 .recall_graph_astar(
411 query,
412 recall_limit,
413 max_hops,
414 temporal_decay_rate,
415 edge_types,
416 )
417 .await
418 }
419 "watercircles" => {
420 let ring_limit = graph_config.watercircles.ring_limit;
421 memory
422 .recall_graph_watercircles(
423 query,
424 recall_limit,
425 max_hops,
426 ring_limit,
427 temporal_decay_rate,
428 edge_types,
429 )
430 .await
431 }
432 "beam_search" => {
433 let beam_width = graph_config.beam_search.beam_width;
434 memory
435 .recall_graph_beam(
436 query,
437 recall_limit,
438 beam_width,
439 max_hops,
440 temporal_decay_rate,
441 edge_types,
442 )
443 .await
444 }
445 _ => {
446 let sa_params = zeph_memory::graph::SpreadingActivationParams {
447 decay_lambda: sa_config.decay_lambda,
448 max_hops: sa_config.max_hops,
449 activation_threshold: sa_config.activation_threshold,
450 inhibition_threshold: sa_config.inhibition_threshold,
451 max_activated_nodes: sa_config.max_activated_nodes,
452 temporal_decay_rate,
453 seed_structural_weight: sa_config.seed_structural_weight,
454 seed_community_cap: sa_config.seed_community_cap,
455 alpha: sa_config.alpha,
456 };
457 memory
458 .recall_graph_activated(query, recall_limit, sa_params, edge_types)
459 .await
460 .map(|activated| {
461 activated
462 .into_iter()
463 .map(|f| zeph_memory::graph::types::GraphFact {
464 entity_name: f.edge.source_entity_id.to_string(),
465 relation: f.edge.relation.clone(),
466 target_name: f.edge.target_entity_id.to_string(),
467 fact: f.edge.fact.clone(),
468 entity_match_score: f.activation_score,
469 hop_distance: 0,
470 confidence: f.edge.confidence,
471 valid_from: Some(f.edge.valid_from.clone()),
472 edge_type: f.edge.edge_type,
473 retrieval_count: f.edge.retrieval_count,
474 edge_id: Some(f.edge.id),
475 })
476 .collect()
477 })
478 }
479 }
480}
481
482#[tracing::instrument(
493 name = "agent_context.helpers.fetch_graph_facts_raw",
494 skip_all,
495 err,
496 fields(effective_strategy)
497)]
498pub async fn fetch_graph_facts_raw(
499 memory: Option<&zeph_memory::semantic::SemanticMemory>,
500 graph_config: &zeph_config::GraphConfig,
501 query: &str,
502 budget_tokens: usize,
503 tc: &TokenCounter,
504) -> Result<Option<Message>, zeph_memory::MemoryError> {
505 if budget_tokens == 0 || !graph_config.enabled {
506 return Ok(None);
507 }
508 let Some(memory) = memory else {
509 return Ok(None);
510 };
511 let recall_limit = graph_config.recall_limit;
512 let temporal_decay_rate = graph_config.temporal_decay_rate;
513 let edge_types = zeph_memory::classify_graph_subgraph(query);
514 let sa_config = &graph_config.spreading_activation;
515
516 let mut body = String::from(GRAPH_FACTS_PREFIX);
517 let mut tokens_so_far = tc.count_tokens(&body);
518 let max_hops = graph_config.max_hops;
519
520 use zeph_config::memory::GraphRetrievalStrategy;
521 let effective_strategy = if sa_config.enabled {
522 GraphRetrievalStrategy::Synapse
523 } else {
524 graph_config.retrieval_strategy
525 };
526
527 tracing::Span::current().record(
528 "effective_strategy",
529 tracing::field::debug(&effective_strategy),
530 );
531 let strategy_str = format!("{effective_strategy:?}").to_lowercase();
532 let edge_types_json = serde_json::to_string(&edge_types).ok();
533
534 dispatch_graph_strategy(
535 effective_strategy,
536 memory,
537 graph_config,
538 sa_config,
539 &strategy_str,
540 query,
541 recall_limit,
542 max_hops,
543 temporal_decay_rate,
544 &edge_types,
545 edge_types_json,
546 &mut body,
547 &mut tokens_so_far,
548 budget_tokens,
549 tc,
550 )
551 .await?;
552
553 if body == GRAPH_FACTS_PREFIX {
554 return Ok(None);
555 }
556
557 Ok(Some(Message::from_legacy(Role::System, body)))
558}
559
560#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
572async fn dispatch_graph_strategy(
573 effective_strategy: zeph_config::memory::GraphRetrievalStrategy,
574 memory: &zeph_memory::semantic::SemanticMemory,
575 graph_config: &zeph_config::GraphConfig,
576 sa_config: &zeph_config::memory::SpreadingActivationConfig,
577 strategy_str: &str,
578 query: &str,
579 recall_limit: usize,
580 max_hops: u32,
581 temporal_decay_rate: f64,
582 edge_types: &[zeph_memory::graph::EdgeType],
583 edge_types_json: Option<String>,
584 body: &mut String,
585 tokens_so_far: &mut usize,
586 budget_tokens: usize,
587 tc: &TokenCounter,
588) -> Result<(), zeph_memory::MemoryError> {
589 use zeph_config::memory::GraphRetrievalStrategy;
590 match effective_strategy {
591 GraphRetrievalStrategy::Synapse => {
592 run_synapse_strategy(
593 memory,
594 sa_config,
595 strategy_str,
596 query,
597 recall_limit,
598 temporal_decay_rate,
599 edge_types,
600 edge_types_json,
601 body,
602 tokens_so_far,
603 budget_tokens,
604 tc,
605 )
606 .await;
607 }
608 GraphRetrievalStrategy::Bfs => {
609 run_graph_strategy(
610 memory,
611 strategy_str,
612 query,
613 edge_types_json,
614 Instant::now(),
615 memory.recall_graph(
616 query,
617 recall_limit,
618 max_hops,
619 None,
620 temporal_decay_rate,
621 edge_types,
622 ),
623 body,
624 tokens_so_far,
625 budget_tokens,
626 tc,
627 )
628 .await?;
629 }
630 GraphRetrievalStrategy::AStar => {
631 run_graph_strategy(
632 memory,
633 strategy_str,
634 query,
635 edge_types_json,
636 Instant::now(),
637 memory.recall_graph_astar(
638 query,
639 recall_limit,
640 max_hops,
641 temporal_decay_rate,
642 edge_types,
643 ),
644 body,
645 tokens_so_far,
646 budget_tokens,
647 tc,
648 )
649 .await?;
650 }
651 GraphRetrievalStrategy::WaterCircles => {
652 let ring_limit = graph_config.watercircles.ring_limit;
653 run_graph_strategy(
654 memory,
655 strategy_str,
656 query,
657 edge_types_json,
658 Instant::now(),
659 memory.recall_graph_watercircles(
660 query,
661 recall_limit,
662 max_hops,
663 ring_limit,
664 temporal_decay_rate,
665 edge_types,
666 ),
667 body,
668 tokens_so_far,
669 budget_tokens,
670 tc,
671 )
672 .await?;
673 }
674 GraphRetrievalStrategy::BeamSearch => {
675 let beam_width = graph_config.beam_search.beam_width;
676 run_graph_strategy(
677 memory,
678 strategy_str,
679 query,
680 edge_types_json,
681 Instant::now(),
682 memory.recall_graph_beam(
683 query,
684 recall_limit,
685 beam_width,
686 max_hops,
687 temporal_decay_rate,
688 edge_types,
689 ),
690 body,
691 tokens_so_far,
692 budget_tokens,
693 tc,
694 )
695 .await?;
696 }
697 GraphRetrievalStrategy::Hybrid => {
698 run_hybrid_strategy(
699 memory,
700 graph_config,
701 sa_config,
702 strategy_str,
703 query,
704 recall_limit,
705 max_hops,
706 temporal_decay_rate,
707 edge_types,
708 edge_types_json,
709 body,
710 tokens_so_far,
711 budget_tokens,
712 tc,
713 )
714 .await?;
715 }
716 _ => {}
717 }
718 Ok(())
719}
720
721#[allow(clippy::too_many_arguments)]
724async fn run_hybrid_strategy(
725 memory: &zeph_memory::semantic::SemanticMemory,
726 graph_config: &zeph_config::GraphConfig,
727 sa_config: &zeph_config::memory::SpreadingActivationConfig,
728 strategy_str: &str,
729 query: &str,
730 recall_limit: usize,
731 max_hops: u32,
732 temporal_decay_rate: f64,
733 edge_types: &[zeph_memory::graph::EdgeType],
734 edge_types_json: Option<String>,
735 body: &mut String,
736 tokens_so_far: &mut usize,
737 budget_tokens: usize,
738 tc: &TokenCounter,
739) -> Result<(), zeph_memory::MemoryError> {
740 let classified = classify_hybrid_strategy(memory, query, edge_types_json.clone()).await;
741 let recall_t0 = Instant::now();
747 let facts_result = recall_by_classified_strategy(
748 &classified,
749 memory,
750 graph_config,
751 sa_config,
752 query,
753 recall_limit,
754 max_hops,
755 temporal_decay_rate,
756 edge_types,
757 )
758 .await;
759
760 run_graph_strategy(
761 memory,
762 strategy_str,
763 query,
764 edge_types_json,
765 recall_t0,
766 std::future::ready(facts_result),
767 body,
768 tokens_so_far,
769 budget_tokens,
770 tc,
771 )
772 .await
773}
774
775#[allow(clippy::too_many_arguments)]
786#[tracing::instrument(
787 name = "agent_context.helpers.fetch_semantic_recall_raw",
788 skip_all,
789 err
790)]
791pub async fn fetch_semantic_recall_raw(
792 memory: Option<&zeph_memory::semantic::SemanticMemory>,
793 recall_limit: usize,
794 context_format: ContextFormat,
795 query: &str,
796 token_budget: usize,
797 tc: &TokenCounter,
798 router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
799 low_confidence_threshold: Option<f32>,
800) -> Result<(Option<Message>, Option<f32>), zeph_memory::MemoryError> {
801 let Some(memory) = memory else {
802 return Ok((None, None));
803 };
804 if recall_limit == 0 || token_budget == 0 {
805 return Ok((None, None));
806 }
807
808 let t0 = Instant::now();
809 let recalled = if let Some(r) = router {
810 memory
811 .recall_routed_async(query, recall_limit, None, r, None)
812 .await?
813 } else {
814 memory.recall(query, recall_limit, None).await?
815 };
816 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
817
818 if recalled.is_empty() {
819 memory.log_retrieval_failure(RetrievalFailureRecord {
820 conversation_id: None,
821 turn_index: 0,
822 failure_type: RetrievalFailureType::NoHit,
823 retrieval_strategy: "semantic".to_owned(),
824 query_text: query.to_owned(),
825 query_len: query.len(),
826 top_score: None,
827 confidence_threshold: low_confidence_threshold,
828 result_count: 0,
829 latency_ms,
830 edge_types: None,
831 error_context: None,
832 });
833 return Ok((None, None));
834 }
835
836 let top_score = recalled.first().map(|r| r.score);
837
838 if let (Some(score), Some(threshold)) = (top_score, low_confidence_threshold)
839 && score < threshold
840 {
841 memory.log_retrieval_failure(RetrievalFailureRecord {
842 conversation_id: None,
843 turn_index: 0,
844 failure_type: RetrievalFailureType::LowConfidence,
845 retrieval_strategy: "semantic".to_owned(),
846 query_text: query.to_owned(),
847 query_len: query.len(),
848 top_score: Some(score),
849 confidence_threshold: Some(threshold),
850 result_count: recalled.len(),
851 latency_ms,
852 edge_types: None,
853 error_context: None,
854 });
855 }
856 let initial_cap = (recall_limit * 512).min(token_budget * 3);
857 let mut recall_text = String::with_capacity(initial_cap);
858 recall_text.push_str(RECALL_PREFIX);
859 let mut tokens_used = tc.count_tokens(&recall_text);
860
861 for item in &recalled {
862 if item.message.content.starts_with("[skipped]")
863 || item.message.content.starts_with("[stopped]")
864 {
865 continue;
866 }
867 let entry = match context_format {
868 ContextFormat::Structured => format_structured_recall_entry(item),
869 _ => format_plain_recall_entry(item),
870 };
871 let entry_tokens = tc.count_tokens(&entry);
872 if tokens_used + entry_tokens > token_budget {
873 break;
874 }
875 recall_text.push_str(&entry);
876 tokens_used += entry_tokens;
877 }
878
879 if tokens_used > tc.count_tokens(RECALL_PREFIX) {
880 Ok((
881 Some(Message::from_parts(
882 Role::System,
883 vec![MessagePart::Recall { text: recall_text }],
884 )),
885 top_score,
886 ))
887 } else {
888 Ok((None, None))
889 }
890}
891
892#[tracing::instrument(name = "agent_context.helpers.fetch_summaries_raw", skip_all, err)]
900pub async fn fetch_summaries_raw(
901 memory: Option<&zeph_memory::semantic::SemanticMemory>,
902 conversation_id: Option<zeph_memory::ConversationId>,
903 token_budget: usize,
904 tc: &TokenCounter,
905) -> Result<Option<Message>, zeph_memory::MemoryError> {
906 let (Some(memory), Some(cid)) = (memory, conversation_id) else {
907 return Ok(None);
908 };
909 if token_budget == 0 {
910 return Ok(None);
911 }
912
913 let summaries = memory.load_summaries(cid).await?;
914 if summaries.is_empty() {
915 return Ok(None);
916 }
917
918 let mut summary_text = String::from(SUMMARY_PREFIX);
919 let mut tokens_used = tc.count_tokens(&summary_text);
920
921 for summary in summaries.iter().rev() {
922 let first = summary.first_message_id.map_or(0, |m| m.0);
923 let last = summary.last_message_id.map_or(0, |m| m.0);
924 let entry = format!("- Messages {first}-{last}: {}\n", summary.content);
925 let cost = tc.count_tokens(&entry);
926 if tokens_used + cost > token_budget {
927 break;
928 }
929 summary_text.push_str(&entry);
930 tokens_used += cost;
931 }
932
933 if tokens_used > tc.count_tokens(SUMMARY_PREFIX) {
934 Ok(Some(Message::from_parts(
935 Role::System,
936 vec![MessagePart::Summary { text: summary_text }],
937 )))
938 } else {
939 Ok(None)
940 }
941}
942
943#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session_raw", skip_all, err)]
951pub async fn fetch_cross_session_raw(
952 memory: Option<&zeph_memory::semantic::SemanticMemory>,
953 conversation_id: Option<zeph_memory::ConversationId>,
954 cross_session_score_threshold: f32,
955 query: &str,
956 token_budget: usize,
957 tc: &TokenCounter,
958) -> Result<Option<Message>, zeph_memory::MemoryError> {
959 let (Some(memory), Some(cid)) = (memory, conversation_id) else {
960 return Ok(None);
961 };
962 if token_budget == 0 {
963 return Ok(None);
964 }
965
966 let results: Vec<_> = memory
967 .search_session_summaries(query, 5, Some(cid))
968 .await?
969 .into_iter()
970 .filter(|r| r.score >= cross_session_score_threshold)
971 .collect();
972 if results.is_empty() {
973 return Ok(None);
974 }
975
976 let mut text = String::from(CROSS_SESSION_PREFIX);
977 let mut tokens_used = tc.count_tokens(&text);
978
979 for item in &results {
980 let entry = format!("- {}\n", item.summary_text);
981 let cost = tc.count_tokens(&entry);
982 if tokens_used + cost > token_budget {
983 break;
984 }
985 text.push_str(&entry);
986 tokens_used += cost;
987 }
988
989 if tokens_used > tc.count_tokens(CROSS_SESSION_PREFIX) {
990 Ok(Some(Message::from_parts(
991 Role::System,
992 vec![MessagePart::CrossSession { text }],
993 )))
994 } else {
995 Ok(None)
996 }
997}
998
999#[tracing::instrument(name = "agent_context.helpers.fetch_semantic_recall", skip_all, err)]
1013pub async fn fetch_semantic_recall(
1014 view: &ContextAssemblyView<'_>,
1015 query: &str,
1016 token_budget: usize,
1017 tc: &TokenCounter,
1018 router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
1019) -> Result<(Option<Message>, Option<f32>), ContextError> {
1020 fetch_semantic_recall_raw(
1021 view.memory.as_deref(),
1022 view.recall_limit,
1023 view.context_format,
1024 query,
1025 token_budget,
1026 tc,
1027 router,
1028 None,
1029 )
1030 .await
1031 .map_err(ContextError::Memory)
1032}
1033
1034fn format_plain_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
1035 let role_label = match item.message.role {
1036 Role::Assistant => "assistant",
1037 Role::System => "system",
1038 Role::User | _ => "user",
1039 };
1040 format!("- [{}] {}\n", role_label, item.message.content)
1041}
1042
1043#[allow(clippy::map_unwrap_or)]
1044fn format_structured_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
1045 let source = match item.message.role {
1046 Role::Assistant => "assistant",
1047 Role::System => "system",
1048 Role::User | _ => "user",
1049 };
1050 let date = item
1055 .message
1056 .metadata
1057 .compacted_at
1058 .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
1059 .map(|dt| dt.format("%Y-%m-%d").to_string())
1060 .unwrap_or_else(|| "unknown".to_owned());
1061 format!(
1062 "[Memory | {} | {} | relevance: {:.2}]\n{}\n",
1063 source, date, item.score, item.message.content
1064 )
1065}
1066
1067#[tracing::instrument(name = "agent_context.helpers.fetch_summaries", skip_all, err)]
1078pub async fn fetch_summaries(
1079 view: &ContextAssemblyView<'_>,
1080 token_budget: usize,
1081 tc: &TokenCounter,
1082) -> Result<Option<Message>, ContextError> {
1083 fetch_summaries_raw(
1084 view.memory.as_deref(),
1085 view.conversation_id,
1086 token_budget,
1087 tc,
1088 )
1089 .await
1090 .map_err(ContextError::Memory)
1091}
1092
1093#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session", skip_all, err)]
1107pub async fn fetch_cross_session(
1108 view: &ContextAssemblyView<'_>,
1109 query: &str,
1110 token_budget: usize,
1111 tc: &TokenCounter,
1112) -> Result<Option<Message>, ContextError> {
1113 fetch_cross_session_raw(
1114 view.memory.as_deref(),
1115 view.conversation_id,
1116 view.cross_session_score_threshold,
1117 query,
1118 token_budget,
1119 tc,
1120 )
1121 .await
1122 .map_err(ContextError::Memory)
1123}
1124
1125pub struct BudgetHint {
1133 pub remaining_cost_cents: Option<f64>,
1135 pub total_budget_cents: Option<f64>,
1137 pub remaining_tool_calls: usize,
1139 pub max_tool_calls: usize,
1141}
1142
1143impl BudgetHint {
1144 #[must_use]
1165 pub fn format_xml(&self) -> Option<String> {
1166 let has_cost = self.remaining_cost_cents.is_some();
1167 if !has_cost && self.max_tool_calls == 0 {
1169 return None;
1170 }
1171 let mut s = String::from("<budget>");
1172 if let Some(remaining) = self.remaining_cost_cents {
1173 let _ = write!(
1174 s,
1175 "\n<remaining_cost_cents>{remaining:.2}</remaining_cost_cents>"
1176 );
1177 }
1178 if let Some(total) = self.total_budget_cents {
1179 let _ = write!(s, "\n<total_budget_cents>{total:.2}</total_budget_cents>");
1180 }
1181 if self.max_tool_calls > 0 {
1182 let _ = write!(
1183 s,
1184 "\n<remaining_tool_calls>{}</remaining_tool_calls>",
1185 self.remaining_tool_calls
1186 );
1187 let _ = write!(
1188 s,
1189 "\n<max_tool_calls>{}</max_tool_calls>",
1190 self.max_tool_calls
1191 );
1192 }
1193 s.push_str("\n</budget>");
1194 Some(s)
1195 }
1196}
1197
1198#[cfg(test)]
1199mod budget_hint_tests {
1200 use super::*;
1201
1202 #[test]
1203 fn format_xml_none_when_no_data() {
1204 let hint = BudgetHint {
1205 remaining_cost_cents: None,
1206 total_budget_cents: None,
1207 remaining_tool_calls: 0,
1208 max_tool_calls: 0,
1209 };
1210 assert!(hint.format_xml().is_none());
1211 }
1212
1213 #[test]
1214 fn format_xml_with_cost_only() {
1215 let hint = BudgetHint {
1216 remaining_cost_cents: Some(25.5),
1217 total_budget_cents: Some(100.0),
1218 remaining_tool_calls: 0,
1219 max_tool_calls: 0,
1220 };
1221 let xml = hint.format_xml().unwrap();
1222 assert!(xml.contains("<remaining_cost_cents>25.50</remaining_cost_cents>"));
1223 assert!(xml.contains("<total_budget_cents>100.00</total_budget_cents>"));
1224 }
1225
1226 #[test]
1227 fn format_xml_with_tool_calls_only() {
1228 let hint = BudgetHint {
1229 remaining_cost_cents: None,
1230 total_budget_cents: None,
1231 remaining_tool_calls: 3,
1232 max_tool_calls: 10,
1233 };
1234 let xml = hint.format_xml().unwrap();
1235 assert!(xml.contains("<remaining_tool_calls>3</remaining_tool_calls>"));
1236 assert!(xml.contains("<max_tool_calls>10</max_tool_calls>"));
1237 }
1238
1239 #[test]
1240 fn format_xml_with_all_fields() {
1241 let hint = BudgetHint {
1242 remaining_cost_cents: Some(50.0),
1243 total_budget_cents: Some(100.0),
1244 remaining_tool_calls: 8,
1245 max_tool_calls: 10,
1246 };
1247 let xml = hint.format_xml().unwrap();
1248 assert!(xml.starts_with("<budget>"));
1249 assert!(xml.ends_with("</budget>"));
1250 }
1251}
1252
1253#[cfg(test)]
1254mod run_graph_strategy_latency_tests {
1255 use std::time::Duration;
1256
1257 use tokio_util::sync::CancellationToken;
1258 use zeph_llm::any::AnyProvider;
1259 use zeph_memory::RetrievalFailureLogger;
1260
1261 use super::*;
1262
1263 #[tokio::test]
1276 async fn latency_is_measured_from_caller_supplied_start_not_from_an_internal_clock() {
1277 let memory = zeph_memory::semantic::SemanticMemory::new(
1278 ":memory:",
1279 "http://127.0.0.1:1",
1280 None,
1281 AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
1282 "test-model",
1283 )
1284 .await
1285 .unwrap();
1286 let sup = zeph_common::TaskSupervisor::new(CancellationToken::new());
1287 let logger = RetrievalFailureLogger::new(
1288 memory.sqlite().clone(),
1289 256,
1290 1, Duration::from_millis(10),
1292 90,
1293 &sup,
1294 );
1295 let memory = memory.with_retrieval_failure_logger(logger);
1296
1297 let mut body = String::from(GRAPH_FACTS_PREFIX);
1298 let mut tokens_so_far = 0usize;
1299 let tc = TokenCounter::new();
1300
1301 let start = Instant::now();
1304 tokio::time::sleep(Duration::from_millis(30)).await;
1305 let facts_result: Result<
1306 Vec<zeph_memory::graph::types::GraphFact>,
1307 zeph_memory::MemoryError,
1308 > = Ok(Vec::new());
1309
1310 run_graph_strategy(
1311 &memory,
1312 "hybrid",
1313 "test query",
1314 None,
1315 start,
1316 std::future::ready(facts_result),
1317 &mut body,
1318 &mut tokens_so_far,
1319 1000,
1320 &tc,
1321 )
1322 .await
1323 .unwrap();
1324
1325 let mut latency_ms: Option<i64> = None;
1327 for _ in 0..50 {
1328 let rows: Vec<(i64,)> = sqlx::query_as(
1329 "SELECT latency_ms FROM memory_retrieval_failures WHERE retrieval_strategy = 'hybrid'",
1330 )
1331 .fetch_all(memory.sqlite().pool())
1332 .await
1333 .unwrap();
1334 if let Some(row) = rows.first() {
1335 latency_ms = Some(row.0);
1336 break;
1337 }
1338 tokio::time::sleep(Duration::from_millis(10)).await;
1339 }
1340
1341 drop(memory);
1345 sup.shutdown_all(Duration::from_secs(5)).await;
1346
1347 let latency_ms =
1348 latency_ms.expect("expected a hybrid NoHit failure record to be persisted");
1349 assert!(
1350 latency_ms >= 25,
1351 "latency_ms should reflect the ~30ms of work done before run_graph_strategy was \
1352 called via the `start` parameter, not ~0ms from a freshly-captured internal \
1353 Instant polling an already-resolved future; got {latency_ms}"
1354 );
1355 }
1356}