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
117#[derive(Clone, Copy)]
127struct GraphStrategyParams<'a> {
128 query: &'a str,
129 recall_limit: usize,
130 max_hops: u32,
131 temporal_decay_rate: f64,
132 edge_types: &'a [zeph_memory::graph::EdgeType],
133 strategy_str: &'a str,
134}
135
136#[derive(Clone, Copy)]
142struct GraphRecallConfig<'a> {
143 graph_config: &'a zeph_config::GraphConfig,
144 sa_config: &'a zeph_config::memory::SpreadingActivationConfig,
145}
146
147struct GraphRecallBudget<'a> {
154 body: &'a mut String,
155 tokens_so_far: &'a mut usize,
156 budget_tokens: usize,
157 tc: &'a TokenCounter,
158}
159
160fn append_graph_facts(
162 facts: &[zeph_memory::graph::types::GraphFact],
163 budget: &mut GraphRecallBudget<'_>,
164) -> usize {
165 let mut count = 0;
166 for f in facts {
167 let fact_text = f.fact.replace(['\n', '\r', '<', '>'], " ");
168 let line = format!("- {} (confidence: {:.2})\n", fact_text, f.confidence);
169 let line_tokens = budget.tc.count_tokens(&line);
170 if *budget.tokens_so_far + line_tokens > budget.budget_tokens {
171 break;
172 }
173 budget.body.push_str(&line);
174 *budget.tokens_so_far += line_tokens;
175 count += 1;
176 }
177 count
178}
179
180async fn run_graph_strategy<F>(
200 memory: &zeph_memory::semantic::SemanticMemory,
201 params: GraphStrategyParams<'_>,
202 edge_types_json: Option<String>,
203 start: Instant,
204 recall: F,
205 budget: &mut GraphRecallBudget<'_>,
206) -> Result<(), zeph_memory::MemoryError>
207where
208 F: Future<Output = Result<Vec<zeph_memory::graph::types::GraphFact>, zeph_memory::MemoryError>>,
209{
210 let facts = match recall.await {
211 Ok(f) => f,
212 Err(e) => {
213 let latency_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
214 memory.log_retrieval_failure(RetrievalFailureRecord {
215 conversation_id: None,
216 turn_index: 0,
217 failure_type: RetrievalFailureType::Error,
218 retrieval_strategy: params.strategy_str.to_owned(),
219 query_text: params.query.to_owned(),
220 query_len: params.query.len(),
221 top_score: None,
222 confidence_threshold: None,
223 result_count: 0,
224 latency_ms,
225 edge_types: edge_types_json,
226 error_context: Some(format!("{e:#}")),
227 });
228 return Err(e);
229 }
230 };
231 let latency_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
232 if facts.is_empty() {
233 memory.log_retrieval_failure(RetrievalFailureRecord {
234 conversation_id: None,
235 turn_index: 0,
236 failure_type: RetrievalFailureType::NoHit,
237 retrieval_strategy: params.strategy_str.to_owned(),
238 query_text: params.query.to_owned(),
239 query_len: params.query.len(),
240 top_score: None,
241 confidence_threshold: None,
242 result_count: 0,
243 latency_ms,
244 edge_types: edge_types_json,
245 error_context: None,
246 });
247 return Ok(());
248 }
249 append_graph_facts(&facts, budget);
250 Ok(())
251}
252
253async fn run_synapse_strategy(
264 memory: &zeph_memory::semantic::SemanticMemory,
265 sa_config: &zeph_config::memory::SpreadingActivationConfig,
266 params: GraphStrategyParams<'_>,
267 edge_types_json: Option<String>,
268 budget: &mut GraphRecallBudget<'_>,
269) {
270 let sa_params = zeph_memory::graph::SpreadingActivationParams {
271 decay_lambda: sa_config.decay_lambda,
272 max_hops: sa_config.max_hops,
273 activation_threshold: sa_config.activation_threshold,
274 inhibition_threshold: sa_config.inhibition_threshold,
275 max_activated_nodes: sa_config.max_activated_nodes,
276 temporal_decay_rate: params.temporal_decay_rate,
277 seed_structural_weight: sa_config.seed_structural_weight,
278 seed_community_cap: sa_config.seed_community_cap,
279 alpha: sa_config.alpha,
280 };
281 let timeout_ms = effective_recall_timeout_ms(sa_config.recall_timeout_ms);
282 let t0 = Instant::now();
283 let activated_facts = match tokio::time::timeout(
284 std::time::Duration::from_millis(timeout_ms),
285 memory.recall_graph_activated(
286 params.query,
287 params.recall_limit,
288 sa_params,
289 params.edge_types,
290 ),
291 )
292 .await
293 {
294 Ok(Ok(facts)) => facts,
295 Ok(Err(e)) => {
296 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
297 tracing::warn!("spreading activation recall failed: {e:#}");
298 memory.log_retrieval_failure(RetrievalFailureRecord {
302 conversation_id: None,
303 turn_index: 0,
304 failure_type: RetrievalFailureType::Error,
305 retrieval_strategy: params.strategy_str.to_owned(),
306 query_text: params.query.to_owned(),
307 query_len: params.query.len(),
308 top_score: None,
309 confidence_threshold: None,
310 result_count: 0,
311 latency_ms,
312 edge_types: edge_types_json.clone(),
313 error_context: Some(format!("{e:#}")),
314 });
315 Vec::new()
316 }
317 Err(_) => {
318 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
319 tracing::warn!("spreading activation recall timed out ({timeout_ms}ms)");
320 memory.log_retrieval_failure(RetrievalFailureRecord {
321 conversation_id: None,
322 turn_index: 0,
323 failure_type: RetrievalFailureType::Timeout,
324 retrieval_strategy: params.strategy_str.to_owned(),
325 query_text: params.query.to_owned(),
326 query_len: params.query.len(),
327 top_score: None,
328 confidence_threshold: None,
329 result_count: 0,
330 latency_ms,
331 edge_types: edge_types_json.clone(),
332 error_context: Some(format!("timeout after {timeout_ms}ms")),
333 });
334 Vec::new()
335 }
336 };
337 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
338 if activated_facts.is_empty() {
339 memory.log_retrieval_failure(RetrievalFailureRecord {
340 conversation_id: None,
341 turn_index: 0,
342 failure_type: RetrievalFailureType::NoHit,
343 retrieval_strategy: params.strategy_str.to_owned(),
344 query_text: params.query.to_owned(),
345 query_len: params.query.len(),
346 top_score: None,
347 confidence_threshold: None,
348 result_count: 0,
349 latency_ms,
350 edge_types: edge_types_json,
351 error_context: None,
352 });
353 return;
354 }
355 for f in &activated_facts {
356 let fact_text = f.edge.fact.replace(['\n', '\r', '<', '>'], " ");
357 let line = format!(
358 "- {} (confidence: {:.2}, activation: {:.2})\n",
359 fact_text, f.edge.confidence, f.activation_score
360 );
361 let line_tokens = budget.tc.count_tokens(&line);
362 if *budget.tokens_so_far + line_tokens > budget.budget_tokens {
363 break;
364 }
365 budget.body.push_str(&line);
366 *budget.tokens_so_far += line_tokens;
367 }
368}
369
370async fn classify_hybrid_strategy(
376 memory: &zeph_memory::semantic::SemanticMemory,
377 query: &str,
378 edge_types_json: Option<String>,
379) -> String {
380 const CLASSIFIER_TIMEOUT_MS: u64 = 2_000;
381 let classifier_t0 = Instant::now();
382 let classified = if let Ok(s) = tokio::time::timeout(
383 std::time::Duration::from_millis(CLASSIFIER_TIMEOUT_MS),
384 memory.classify_graph_strategy(query),
385 )
386 .await
387 {
388 s
389 } else {
390 let latency_ms = classifier_t0
391 .elapsed()
392 .as_millis()
393 .try_into()
394 .unwrap_or(u64::MAX);
395 tracing::warn!(
396 "hybrid strategy classifier timed out after {CLASSIFIER_TIMEOUT_MS}ms, \
397 falling back to synapse"
398 );
399 memory.log_retrieval_failure(RetrievalFailureRecord {
400 conversation_id: None,
401 turn_index: 0,
402 failure_type: RetrievalFailureType::Timeout,
403 retrieval_strategy: "hybrid_classifier".to_owned(),
404 query_text: query.to_owned(),
405 query_len: query.len(),
406 top_score: None,
407 confidence_threshold: None,
408 result_count: 0,
409 latency_ms,
410 edge_types: edge_types_json,
411 error_context: Some(format!(
412 "classifier timeout after {CLASSIFIER_TIMEOUT_MS}ms"
413 )),
414 });
415 "synapse".to_owned()
416 };
417 tracing::debug!(classified_strategy = %classified, "hybrid dispatch: classified");
418 classified
419}
420
421async fn recall_by_classified_strategy(
428 classified: &str,
429 memory: &zeph_memory::semantic::SemanticMemory,
430 config: GraphRecallConfig<'_>,
431 params: GraphStrategyParams<'_>,
432) -> Result<Vec<zeph_memory::graph::types::GraphFact>, zeph_memory::MemoryError> {
433 match classified {
434 "astar" => {
435 memory
436 .recall_graph_astar(
437 params.query,
438 params.recall_limit,
439 params.max_hops,
440 params.temporal_decay_rate,
441 params.edge_types,
442 )
443 .await
444 }
445 "watercircles" => {
446 let ring_limit = config.graph_config.watercircles.ring_limit;
447 memory
448 .recall_graph_watercircles(
449 params.query,
450 params.recall_limit,
451 params.max_hops,
452 ring_limit,
453 params.temporal_decay_rate,
454 params.edge_types,
455 )
456 .await
457 }
458 "beam_search" => {
459 let beam_width = config.graph_config.beam_search.beam_width;
460 memory
461 .recall_graph_beam(
462 params.query,
463 params.recall_limit,
464 beam_width,
465 params.max_hops,
466 params.temporal_decay_rate,
467 params.edge_types,
468 )
469 .await
470 }
471 _ => {
472 let sa_params = zeph_memory::graph::SpreadingActivationParams {
473 decay_lambda: config.sa_config.decay_lambda,
474 max_hops: config.sa_config.max_hops,
475 activation_threshold: config.sa_config.activation_threshold,
476 inhibition_threshold: config.sa_config.inhibition_threshold,
477 max_activated_nodes: config.sa_config.max_activated_nodes,
478 temporal_decay_rate: params.temporal_decay_rate,
479 seed_structural_weight: config.sa_config.seed_structural_weight,
480 seed_community_cap: config.sa_config.seed_community_cap,
481 alpha: config.sa_config.alpha,
482 };
483 memory
484 .recall_graph_activated(
485 params.query,
486 params.recall_limit,
487 sa_params,
488 params.edge_types,
489 )
490 .await
491 .map(|activated| {
492 activated
493 .into_iter()
494 .map(|f| zeph_memory::graph::types::GraphFact {
495 entity_name: f.edge.source_entity_id.to_string(),
496 relation: f.edge.relation.clone(),
497 target_name: f.edge.target_entity_id.to_string(),
498 fact: f.edge.fact.clone(),
499 entity_match_score: f.activation_score,
500 hop_distance: 0,
501 confidence: f.edge.confidence,
502 valid_from: Some(f.edge.valid_from.clone()),
503 edge_type: f.edge.edge_type,
504 retrieval_count: f.edge.retrieval_count,
505 edge_id: Some(f.edge.id),
506 })
507 .collect()
508 })
509 }
510 }
511}
512
513#[tracing::instrument(
524 name = "agent_context.helpers.fetch_graph_facts_raw",
525 skip_all,
526 err,
527 fields(effective_strategy)
528)]
529pub async fn fetch_graph_facts_raw(
530 memory: Option<&zeph_memory::semantic::SemanticMemory>,
531 graph_config: &zeph_config::GraphConfig,
532 query: &str,
533 budget_tokens: usize,
534 tc: &TokenCounter,
535) -> Result<Option<Message>, zeph_memory::MemoryError> {
536 if budget_tokens == 0 || !graph_config.enabled {
537 return Ok(None);
538 }
539 let Some(memory) = memory else {
540 return Ok(None);
541 };
542 let recall_limit = graph_config.recall_limit;
543 let temporal_decay_rate = graph_config.temporal_decay_rate;
544 let edge_types = zeph_memory::classify_graph_subgraph(query);
545 let sa_config = &graph_config.spreading_activation;
546
547 let mut body = String::from(GRAPH_FACTS_PREFIX);
548 let mut tokens_so_far = tc.count_tokens(&body);
549 let max_hops = graph_config.max_hops;
550
551 use zeph_config::memory::GraphRetrievalStrategy;
552 let effective_strategy = if sa_config.enabled {
553 GraphRetrievalStrategy::Synapse
554 } else {
555 graph_config.retrieval_strategy
556 };
557
558 tracing::Span::current().record(
559 "effective_strategy",
560 tracing::field::debug(&effective_strategy),
561 );
562 let strategy_str = format!("{effective_strategy:?}").to_lowercase();
563 let edge_types_json = serde_json::to_string(&edge_types).ok();
564
565 let params = GraphStrategyParams {
566 query,
567 recall_limit,
568 max_hops,
569 temporal_decay_rate,
570 edge_types: &edge_types,
571 strategy_str: &strategy_str,
572 };
573 let config = GraphRecallConfig {
574 graph_config,
575 sa_config,
576 };
577 let mut budget = GraphRecallBudget {
578 body: &mut body,
579 tokens_so_far: &mut tokens_so_far,
580 budget_tokens,
581 tc,
582 };
583
584 dispatch_graph_strategy(
585 effective_strategy,
586 memory,
587 config,
588 params,
589 edge_types_json,
590 &mut budget,
591 )
592 .await?;
593
594 if body == GRAPH_FACTS_PREFIX {
595 return Ok(None);
596 }
597
598 Ok(Some(Message::from_legacy(Role::System, body)))
599}
600
601#[allow(clippy::too_many_lines)]
613async fn dispatch_graph_strategy(
614 effective_strategy: zeph_config::memory::GraphRetrievalStrategy,
615 memory: &zeph_memory::semantic::SemanticMemory,
616 config: GraphRecallConfig<'_>,
617 params: GraphStrategyParams<'_>,
618 edge_types_json: Option<String>,
619 budget: &mut GraphRecallBudget<'_>,
620) -> Result<(), zeph_memory::MemoryError> {
621 use zeph_config::memory::GraphRetrievalStrategy;
622 match effective_strategy {
623 GraphRetrievalStrategy::Synapse => {
624 run_synapse_strategy(memory, config.sa_config, params, edge_types_json, budget).await;
625 }
626 GraphRetrievalStrategy::Bfs => {
627 run_graph_strategy(
628 memory,
629 params,
630 edge_types_json,
631 Instant::now(),
632 memory.recall_graph(
633 params.query,
634 params.recall_limit,
635 params.max_hops,
636 None,
637 params.temporal_decay_rate,
638 params.edge_types,
639 ),
640 budget,
641 )
642 .await?;
643 }
644 GraphRetrievalStrategy::AStar => {
645 run_graph_strategy(
646 memory,
647 params,
648 edge_types_json,
649 Instant::now(),
650 memory.recall_graph_astar(
651 params.query,
652 params.recall_limit,
653 params.max_hops,
654 params.temporal_decay_rate,
655 params.edge_types,
656 ),
657 budget,
658 )
659 .await?;
660 }
661 GraphRetrievalStrategy::WaterCircles => {
662 let ring_limit = config.graph_config.watercircles.ring_limit;
663 run_graph_strategy(
664 memory,
665 params,
666 edge_types_json,
667 Instant::now(),
668 memory.recall_graph_watercircles(
669 params.query,
670 params.recall_limit,
671 params.max_hops,
672 ring_limit,
673 params.temporal_decay_rate,
674 params.edge_types,
675 ),
676 budget,
677 )
678 .await?;
679 }
680 GraphRetrievalStrategy::BeamSearch => {
681 let beam_width = config.graph_config.beam_search.beam_width;
682 run_graph_strategy(
683 memory,
684 params,
685 edge_types_json,
686 Instant::now(),
687 memory.recall_graph_beam(
688 params.query,
689 params.recall_limit,
690 beam_width,
691 params.max_hops,
692 params.temporal_decay_rate,
693 params.edge_types,
694 ),
695 budget,
696 )
697 .await?;
698 }
699 GraphRetrievalStrategy::Hybrid => {
700 run_hybrid_strategy(memory, config, params, edge_types_json, budget).await?;
701 }
702 _ => {}
703 }
704 Ok(())
705}
706
707async fn run_hybrid_strategy(
710 memory: &zeph_memory::semantic::SemanticMemory,
711 config: GraphRecallConfig<'_>,
712 params: GraphStrategyParams<'_>,
713 edge_types_json: Option<String>,
714 budget: &mut GraphRecallBudget<'_>,
715) -> Result<(), zeph_memory::MemoryError> {
716 let classified = classify_hybrid_strategy(memory, params.query, edge_types_json.clone()).await;
717 let recall_t0 = Instant::now();
723 let facts_result = recall_by_classified_strategy(&classified, memory, config, params).await;
724
725 run_graph_strategy(
726 memory,
727 params,
728 edge_types_json,
729 recall_t0,
730 std::future::ready(facts_result),
731 budget,
732 )
733 .await
734}
735
736pub struct SemanticRecallRawParams<'a> {
746 pub recall_limit: usize,
748 pub context_format: ContextFormat,
750 pub query: &'a str,
752 pub token_budget: usize,
754 pub tc: &'a TokenCounter,
756 pub low_confidence_threshold: Option<f32>,
759}
760
761#[tracing::instrument(
770 name = "agent_context.helpers.fetch_semantic_recall_raw",
771 skip_all,
772 err
773)]
774pub async fn fetch_semantic_recall_raw(
775 memory: Option<&zeph_memory::semantic::SemanticMemory>,
776 params: SemanticRecallRawParams<'_>,
777 router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
778) -> Result<(Option<Message>, Option<f32>), zeph_memory::MemoryError> {
779 let Some(memory) = memory else {
780 return Ok((None, None));
781 };
782 if params.recall_limit == 0 || params.token_budget == 0 {
783 return Ok((None, None));
784 }
785
786 let t0 = Instant::now();
787 let recalled = if let Some(r) = router {
788 memory
789 .recall_routed_async(params.query, params.recall_limit, None, r, None)
790 .await?
791 } else {
792 memory
793 .recall(params.query, params.recall_limit, None)
794 .await?
795 };
796 let latency_ms = t0.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
797
798 if recalled.is_empty() {
799 memory.log_retrieval_failure(RetrievalFailureRecord {
800 conversation_id: None,
801 turn_index: 0,
802 failure_type: RetrievalFailureType::NoHit,
803 retrieval_strategy: "semantic".to_owned(),
804 query_text: params.query.to_owned(),
805 query_len: params.query.len(),
806 top_score: None,
807 confidence_threshold: params.low_confidence_threshold,
808 result_count: 0,
809 latency_ms,
810 edge_types: None,
811 error_context: None,
812 });
813 return Ok((None, None));
814 }
815
816 let top_score = recalled.first().map(|r| r.score);
817
818 if let (Some(score), Some(threshold)) = (top_score, params.low_confidence_threshold)
819 && score < threshold
820 {
821 memory.log_retrieval_failure(RetrievalFailureRecord {
822 conversation_id: None,
823 turn_index: 0,
824 failure_type: RetrievalFailureType::LowConfidence,
825 retrieval_strategy: "semantic".to_owned(),
826 query_text: params.query.to_owned(),
827 query_len: params.query.len(),
828 top_score: Some(score),
829 confidence_threshold: Some(threshold),
830 result_count: recalled.len(),
831 latency_ms,
832 edge_types: None,
833 error_context: None,
834 });
835 }
836 let initial_cap = (params.recall_limit * 512).min(params.token_budget * 3);
837 let mut recall_text = String::with_capacity(initial_cap);
838 recall_text.push_str(RECALL_PREFIX);
839 let mut tokens_used = params.tc.count_tokens(&recall_text);
840
841 for item in &recalled {
842 if item.message.content.starts_with("[skipped]")
843 || item.message.content.starts_with("[stopped]")
844 {
845 continue;
846 }
847 let entry = match params.context_format {
848 ContextFormat::Structured => format_structured_recall_entry(item),
849 _ => format_plain_recall_entry(item),
850 };
851 let entry_tokens = params.tc.count_tokens(&entry);
852 if tokens_used + entry_tokens > params.token_budget {
853 break;
854 }
855 recall_text.push_str(&entry);
856 tokens_used += entry_tokens;
857 }
858
859 if tokens_used > params.tc.count_tokens(RECALL_PREFIX) {
860 Ok((
861 Some(Message::from_parts(
862 Role::System,
863 vec![MessagePart::Recall { text: recall_text }],
864 )),
865 top_score,
866 ))
867 } else {
868 Ok((None, None))
869 }
870}
871
872#[tracing::instrument(name = "agent_context.helpers.fetch_summaries_raw", skip_all, err)]
880pub async fn fetch_summaries_raw(
881 memory: Option<&zeph_memory::semantic::SemanticMemory>,
882 conversation_id: Option<zeph_memory::ConversationId>,
883 token_budget: usize,
884 tc: &TokenCounter,
885) -> Result<Option<Message>, zeph_memory::MemoryError> {
886 let (Some(memory), Some(cid)) = (memory, conversation_id) else {
887 return Ok(None);
888 };
889 if token_budget == 0 {
890 return Ok(None);
891 }
892
893 let summaries = memory.load_summaries(cid).await?;
894 if summaries.is_empty() {
895 return Ok(None);
896 }
897
898 let mut summary_text = String::from(SUMMARY_PREFIX);
899 let mut tokens_used = tc.count_tokens(&summary_text);
900
901 for summary in summaries.iter().rev() {
902 let first = summary.first_message_id.map_or(0, |m| m.0);
903 let last = summary.last_message_id.map_or(0, |m| m.0);
904 let entry = format!("- Messages {first}-{last}: {}\n", summary.content);
905 let cost = tc.count_tokens(&entry);
906 if tokens_used + cost > token_budget {
907 break;
908 }
909 summary_text.push_str(&entry);
910 tokens_used += cost;
911 }
912
913 if tokens_used > tc.count_tokens(SUMMARY_PREFIX) {
914 Ok(Some(Message::from_parts(
915 Role::System,
916 vec![MessagePart::Summary { text: summary_text }],
917 )))
918 } else {
919 Ok(None)
920 }
921}
922
923#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session_raw", skip_all, err)]
931pub async fn fetch_cross_session_raw(
932 memory: Option<&zeph_memory::semantic::SemanticMemory>,
933 conversation_id: Option<zeph_memory::ConversationId>,
934 cross_session_score_threshold: f32,
935 query: &str,
936 token_budget: usize,
937 tc: &TokenCounter,
938) -> Result<Option<Message>, zeph_memory::MemoryError> {
939 let (Some(memory), Some(cid)) = (memory, conversation_id) else {
940 return Ok(None);
941 };
942 if token_budget == 0 {
943 return Ok(None);
944 }
945
946 let results: Vec<_> = memory
947 .search_session_summaries(query, 5, Some(cid))
948 .await?
949 .into_iter()
950 .filter(|r| r.score >= cross_session_score_threshold)
951 .collect();
952 if results.is_empty() {
953 return Ok(None);
954 }
955
956 let mut text = String::from(CROSS_SESSION_PREFIX);
957 let mut tokens_used = tc.count_tokens(&text);
958
959 for item in &results {
960 let entry = format!("- {}\n", item.summary_text);
961 let cost = tc.count_tokens(&entry);
962 if tokens_used + cost > token_budget {
963 break;
964 }
965 text.push_str(&entry);
966 tokens_used += cost;
967 }
968
969 if tokens_used > tc.count_tokens(CROSS_SESSION_PREFIX) {
970 Ok(Some(Message::from_parts(
971 Role::System,
972 vec![MessagePart::CrossSession { text }],
973 )))
974 } else {
975 Ok(None)
976 }
977}
978
979#[tracing::instrument(name = "agent_context.helpers.fetch_semantic_recall", skip_all, err)]
993pub async fn fetch_semantic_recall(
994 view: &ContextAssemblyView<'_>,
995 query: &str,
996 token_budget: usize,
997 tc: &TokenCounter,
998 router: Option<&dyn zeph_memory::AsyncMemoryRouter>,
999) -> Result<(Option<Message>, Option<f32>), ContextError> {
1000 fetch_semantic_recall_raw(
1001 view.memory.as_deref(),
1002 SemanticRecallRawParams {
1003 recall_limit: view.recall_limit,
1004 context_format: view.context_format,
1005 query,
1006 token_budget,
1007 tc,
1008 low_confidence_threshold: None,
1009 },
1010 router,
1011 )
1012 .await
1013 .map_err(ContextError::Memory)
1014}
1015
1016fn format_plain_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
1017 let role_label = match item.message.role {
1018 Role::Assistant => "assistant",
1019 Role::System => "system",
1020 Role::User | _ => "user",
1021 };
1022 format!("- [{}] {}\n", role_label, item.message.content)
1023}
1024
1025#[allow(clippy::map_unwrap_or)]
1026fn format_structured_recall_entry(item: &zeph_memory::RecalledMessage) -> String {
1027 let source = match item.message.role {
1028 Role::Assistant => "assistant",
1029 Role::System => "system",
1030 Role::User | _ => "user",
1031 };
1032 let date = item
1037 .message
1038 .metadata
1039 .compacted_at
1040 .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
1041 .map(|dt| dt.format("%Y-%m-%d").to_string())
1042 .unwrap_or_else(|| "unknown".to_owned());
1043 format!(
1044 "[Memory | {} | {} | relevance: {:.2}]\n{}\n",
1045 source, date, item.score, item.message.content
1046 )
1047}
1048
1049#[tracing::instrument(name = "agent_context.helpers.fetch_summaries", skip_all, err)]
1060pub async fn fetch_summaries(
1061 view: &ContextAssemblyView<'_>,
1062 token_budget: usize,
1063 tc: &TokenCounter,
1064) -> Result<Option<Message>, ContextError> {
1065 fetch_summaries_raw(
1066 view.memory.as_deref(),
1067 view.conversation_id,
1068 token_budget,
1069 tc,
1070 )
1071 .await
1072 .map_err(ContextError::Memory)
1073}
1074
1075#[tracing::instrument(name = "agent_context.helpers.fetch_cross_session", skip_all, err)]
1089pub async fn fetch_cross_session(
1090 view: &ContextAssemblyView<'_>,
1091 query: &str,
1092 token_budget: usize,
1093 tc: &TokenCounter,
1094) -> Result<Option<Message>, ContextError> {
1095 fetch_cross_session_raw(
1096 view.memory.as_deref(),
1097 view.conversation_id,
1098 view.cross_session_score_threshold,
1099 query,
1100 token_budget,
1101 tc,
1102 )
1103 .await
1104 .map_err(ContextError::Memory)
1105}
1106
1107pub struct BudgetHint {
1115 pub remaining_cost_cents: Option<f64>,
1117 pub total_budget_cents: Option<f64>,
1119 pub remaining_tool_calls: usize,
1121 pub max_tool_calls: usize,
1123}
1124
1125impl BudgetHint {
1126 #[must_use]
1147 pub fn format_xml(&self) -> Option<String> {
1148 let has_cost = self.remaining_cost_cents.is_some();
1149 if !has_cost && self.max_tool_calls == 0 {
1151 return None;
1152 }
1153 let mut s = String::from("<budget>");
1154 if let Some(remaining) = self.remaining_cost_cents {
1155 let _ = write!(
1156 s,
1157 "\n<remaining_cost_cents>{remaining:.2}</remaining_cost_cents>"
1158 );
1159 }
1160 if let Some(total) = self.total_budget_cents {
1161 let _ = write!(s, "\n<total_budget_cents>{total:.2}</total_budget_cents>");
1162 }
1163 if self.max_tool_calls > 0 {
1164 let _ = write!(
1165 s,
1166 "\n<remaining_tool_calls>{}</remaining_tool_calls>",
1167 self.remaining_tool_calls
1168 );
1169 let _ = write!(
1170 s,
1171 "\n<max_tool_calls>{}</max_tool_calls>",
1172 self.max_tool_calls
1173 );
1174 }
1175 s.push_str("\n</budget>");
1176 Some(s)
1177 }
1178}
1179
1180#[cfg(test)]
1181mod budget_hint_tests {
1182 use super::*;
1183
1184 #[test]
1185 fn format_xml_none_when_no_data() {
1186 let hint = BudgetHint {
1187 remaining_cost_cents: None,
1188 total_budget_cents: None,
1189 remaining_tool_calls: 0,
1190 max_tool_calls: 0,
1191 };
1192 assert!(hint.format_xml().is_none());
1193 }
1194
1195 #[test]
1196 fn format_xml_with_cost_only() {
1197 let hint = BudgetHint {
1198 remaining_cost_cents: Some(25.5),
1199 total_budget_cents: Some(100.0),
1200 remaining_tool_calls: 0,
1201 max_tool_calls: 0,
1202 };
1203 let xml = hint.format_xml().unwrap();
1204 assert!(xml.contains("<remaining_cost_cents>25.50</remaining_cost_cents>"));
1205 assert!(xml.contains("<total_budget_cents>100.00</total_budget_cents>"));
1206 }
1207
1208 #[test]
1209 fn format_xml_with_tool_calls_only() {
1210 let hint = BudgetHint {
1211 remaining_cost_cents: None,
1212 total_budget_cents: None,
1213 remaining_tool_calls: 3,
1214 max_tool_calls: 10,
1215 };
1216 let xml = hint.format_xml().unwrap();
1217 assert!(xml.contains("<remaining_tool_calls>3</remaining_tool_calls>"));
1218 assert!(xml.contains("<max_tool_calls>10</max_tool_calls>"));
1219 }
1220
1221 #[test]
1222 fn format_xml_with_all_fields() {
1223 let hint = BudgetHint {
1224 remaining_cost_cents: Some(50.0),
1225 total_budget_cents: Some(100.0),
1226 remaining_tool_calls: 8,
1227 max_tool_calls: 10,
1228 };
1229 let xml = hint.format_xml().unwrap();
1230 assert!(xml.starts_with("<budget>"));
1231 assert!(xml.ends_with("</budget>"));
1232 }
1233}
1234
1235#[cfg(test)]
1236mod run_graph_strategy_latency_tests {
1237 use std::time::Duration;
1238
1239 use tokio_util::sync::CancellationToken;
1240 use zeph_llm::any::AnyProvider;
1241 use zeph_memory::RetrievalFailureLogger;
1242
1243 use super::*;
1244
1245 #[tokio::test]
1258 async fn latency_is_measured_from_caller_supplied_start_not_from_an_internal_clock() {
1259 let memory = zeph_memory::semantic::SemanticMemory::new(
1260 ":memory:",
1261 "http://127.0.0.1:1",
1262 None,
1263 AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
1264 "test-model",
1265 )
1266 .await
1267 .unwrap();
1268 let sup = zeph_common::TaskSupervisor::new(CancellationToken::new());
1269 let logger = RetrievalFailureLogger::new(
1270 memory.sqlite().clone(),
1271 256,
1272 1, Duration::from_millis(10),
1274 90,
1275 &sup,
1276 );
1277 let memory = memory.with_retrieval_failure_logger(logger);
1278
1279 let mut body = String::from(GRAPH_FACTS_PREFIX);
1280 let mut tokens_so_far = 0usize;
1281 let tc = TokenCounter::new();
1282 let edge_types: Vec<zeph_memory::graph::EdgeType> = Vec::new();
1283
1284 let start = Instant::now();
1287 tokio::time::sleep(Duration::from_millis(30)).await;
1288 let facts_result: Result<
1289 Vec<zeph_memory::graph::types::GraphFact>,
1290 zeph_memory::MemoryError,
1291 > = Ok(Vec::new());
1292
1293 let params = GraphStrategyParams {
1294 query: "test query",
1295 recall_limit: 0,
1296 max_hops: 0,
1297 temporal_decay_rate: 0.0,
1298 edge_types: &edge_types,
1299 strategy_str: "hybrid",
1300 };
1301 let mut budget = GraphRecallBudget {
1302 body: &mut body,
1303 tokens_so_far: &mut tokens_so_far,
1304 budget_tokens: 1000,
1305 tc: &tc,
1306 };
1307
1308 run_graph_strategy(
1309 &memory,
1310 params,
1311 None,
1312 start,
1313 std::future::ready(facts_result),
1314 &mut budget,
1315 )
1316 .await
1317 .unwrap();
1318
1319 let mut latency_ms: Option<i64> = None;
1321 for _ in 0..50 {
1322 let rows: Vec<(i64,)> = sqlx::query_as(
1323 "SELECT latency_ms FROM memory_retrieval_failures WHERE retrieval_strategy = 'hybrid'",
1324 )
1325 .fetch_all(memory.sqlite().pool())
1326 .await
1327 .unwrap();
1328 if let Some(row) = rows.first() {
1329 latency_ms = Some(row.0);
1330 break;
1331 }
1332 tokio::time::sleep(Duration::from_millis(10)).await;
1333 }
1334
1335 drop(memory);
1339 sup.shutdown_all(Duration::from_secs(5)).await;
1340
1341 let latency_ms =
1342 latency_ms.expect("expected a hybrid NoHit failure record to be persisted");
1343 assert!(
1344 latency_ms >= 25,
1345 "latency_ms should reflect the ~30ms of work done before run_graph_strategy was \
1346 called via the `start` parameter, not ~0ms from a freshly-captured internal \
1347 Instant polling an already-resolved future; got {latency_ms}"
1348 );
1349 }
1350}