1use std::fmt::Write as _;
14use std::future::Future;
15use std::pin::Pin;
16use std::sync::Arc;
17use std::time::Duration;
18
19use tracing::Instrument as _;
20use zeph_commands::{CommandError, GraphAccess};
21use zeph_memory::semantic::SemanticMemory;
22use zeph_memory::{Edge, Entity, GraphExtractionConfig, GraphStore, extract_and_store};
23
24use super::Agent;
25use crate::channel::Channel;
26
27impl<C: Channel + Send + 'static> Agent<C> {
28 fn resolve_graph_store(&self) -> Result<(Arc<SemanticMemory>, Arc<GraphStore>), String> {
29 let Some(memory) = self.services.memory.persistence.memory.clone() else {
30 return Err("Graph memory is not enabled.".to_owned());
31 };
32 let Some(store) = memory.graph_store.clone() else {
33 if self.services.memory.extraction.graph_config.enabled {
34 return Err(
35 "Graph memory enabled but vector store unavailable (Qdrant unreachable)."
36 .to_owned(),
37 );
38 }
39 return Err("Graph memory is not enabled.".to_owned());
40 };
41 Ok((memory, store))
42 }
43}
44
45enum EntityLookup {
49 Found(Entity),
50 Message(String),
51}
52
53enum StoreCallOutcome<T> {
56 Completed(T),
57 TimedOut,
58}
59
60async fn with_graph_store_timeout<T>(
65 fut: impl Future<Output = Result<T, zeph_memory::MemoryError>>,
66) -> Result<StoreCallOutcome<T>, CommandError> {
67 match tokio::time::timeout(Duration::from_secs(5), fut).await {
68 Ok(Ok(v)) => Ok(StoreCallOutcome::Completed(v)),
69 Ok(Err(e)) => Err(CommandError::new(e.to_string())),
70 Err(_) => {
71 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
72 Ok(StoreCallOutcome::TimedOut)
73 }
74 }
75}
76
77async fn resolve_entity_by_name(
80 store: &GraphStore,
81 name: &str,
82) -> Result<EntityLookup, CommandError> {
83 let matches = match with_graph_store_timeout(store.find_entity_by_name(name)).await? {
84 StoreCallOutcome::Completed(v) => v,
85 StoreCallOutcome::TimedOut => {
86 return Ok(EntityLookup::Message(
87 "Graph store unavailable (Qdrant unreachable).".to_owned(),
88 ));
89 }
90 };
91 let Some(entity) = matches.into_iter().next() else {
92 return Ok(EntityLookup::Message(format!(
93 "No entity found matching '{name}'."
94 )));
95 };
96 Ok(EntityLookup::Found(entity))
97}
98
99async fn build_entity_name_map(
104 store: &GraphStore,
105 entity: &Entity,
106 edges: &[Edge],
107) -> std::collections::HashMap<i64, String> {
108 let mut entity_names: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
109 entity_names.insert(entity.id.0, entity.name.clone());
110 for edge in edges {
111 entity_names.entry(edge.source_entity_id).or_default();
112 entity_names.entry(edge.target_entity_id).or_default();
113 }
114 for (&id, name_val) in &mut entity_names {
115 if name_val.is_empty() {
116 let result =
117 tokio::time::timeout(Duration::from_secs(5), store.find_entity_by_id(id)).await;
118 if let Ok(Ok(Some(other))) = result {
119 *name_val = other.name;
120 } else {
121 *name_val = format!("#{id}");
122 }
123 }
124 }
125 entity_names
126}
127
128impl<C: Channel + Send + 'static> GraphAccess for Agent<C> {
129 fn graph_stats<'a>(
132 &'a mut self,
133 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
134 Box::pin(
135 async move {
136 let (_, store) = match self.resolve_graph_store() {
137 Ok(pair) => pair,
138 Err(msg) => return Ok(msg),
139 };
140
141 let stats_future = async {
142 tokio::join!(
143 store.entity_count(),
144 store.active_edge_count(),
145 store.community_count(),
146 store.edge_type_distribution()
147 )
148 };
149 let Ok((entities, edges, communities, distribution)) =
150 tokio::time::timeout(Duration::from_secs(5), stats_future).await
151 else {
152 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
153 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
154 };
155 let mut msg = format!(
156 "Graph memory: {} entities, {} edges, {} communities",
157 entities.unwrap_or(0),
158 edges.unwrap_or(0),
159 communities.unwrap_or(0)
160 );
161 if let Ok(dist) = distribution
162 && !dist.is_empty()
163 {
164 let dist_str: Vec<String> =
165 dist.iter().map(|(t, c)| format!("{t}={c}")).collect();
166 write!(msg, "\nEdge types: {}", dist_str.join(", ")).unwrap_or(());
167 }
168 Ok(msg)
169 }
170 .instrument(tracing::info_span!("core.agent_access.graph_stats")),
171 )
172 }
173
174 fn graph_entities<'a>(
175 &'a mut self,
176 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
177 Box::pin(
178 async move {
179 let (_, store) = match self.resolve_graph_store() {
180 Ok(pair) => pair,
181 Err(msg) => return Ok(msg),
182 };
183
184 let entities = match tokio::time::timeout(
185 Duration::from_secs(5),
186 store.all_entities(),
187 )
188 .await
189 {
190 Ok(Ok(v)) => v,
191 Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
192 Err(_) => {
193 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
194 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
195 }
196 };
197 if entities.is_empty() {
198 return Ok("No entities found.".to_owned());
199 }
200
201 let total = entities.len();
202 let display: Vec<String> = entities
203 .iter()
204 .take(50)
205 .map(|e| {
206 format!(
207 " {:<40} {:<15} {}",
208 e.name,
209 e.entity_type.as_str(),
210 e.last_seen_at.split('T').next().unwrap_or(&e.last_seen_at)
211 )
212 })
213 .collect();
214 let mut msg = format!(
215 "Entities ({total} total):\n {:<40} {:<15} {}\n{}",
216 "NAME",
217 "TYPE",
218 "LAST SEEN",
219 display.join("\n")
220 );
221 if total > 50 {
222 write!(msg, "\n ...and {} more", total - 50).unwrap_or(());
223 }
224 Ok(msg)
225 }
226 .instrument(tracing::info_span!("core.agent_access.graph_entities")),
227 )
228 }
229
230 fn graph_facts<'a>(
231 &'a mut self,
232 name: &'a str,
233 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
234 Box::pin(
235 async move {
236 let (_, store) = match self.resolve_graph_store() {
237 Ok(pair) => pair,
238 Err(msg) => return Ok(msg),
239 };
240
241 let entity = match resolve_entity_by_name(&store, name).await? {
242 EntityLookup::Found(e) => e,
243 EntityLookup::Message(msg) => return Ok(msg),
244 };
245
246 let edges =
247 match with_graph_store_timeout(store.edges_for_entity(entity.id.0)).await? {
248 StoreCallOutcome::Completed(v) => v,
249 StoreCallOutcome::TimedOut => {
250 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
251 }
252 };
253 if edges.is_empty() {
254 return Ok(format!("Entity '{}' has no known facts.", entity.name));
255 }
256
257 let entity_names = build_entity_name_map(&store, &entity, &edges).await;
258
259 let lines: Vec<String> = edges
260 .iter()
261 .map(|e| {
262 let src = entity_names
263 .get(&e.source_entity_id)
264 .cloned()
265 .unwrap_or_else(|| format!("#{}", e.source_entity_id));
266 let tgt = entity_names
267 .get(&e.target_entity_id)
268 .cloned()
269 .unwrap_or_else(|| format!("#{}", e.target_entity_id));
270 format!(
271 " {} --[{}/{}]--> {}: {} (confidence: {:.2})",
272 src, e.relation, e.edge_type, tgt, e.fact, e.confidence
273 )
274 })
275 .collect();
276 Ok(format!(
277 "Facts for '{}':\n{}",
278 entity.name,
279 lines.join("\n")
280 ))
281 }
282 .instrument(tracing::info_span!("core.agent_access.graph_facts")),
283 )
284 }
285
286 fn graph_history<'a>(
287 &'a mut self,
288 name: &'a str,
289 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
290 Box::pin(
291 async move {
292 let (_, store) = match self.resolve_graph_store() {
293 Ok(pair) => pair,
294 Err(msg) => return Ok(msg),
295 };
296
297 let entity = match resolve_entity_by_name(&store, name).await? {
298 EntityLookup::Found(e) => e,
299 EntityLookup::Message(msg) => return Ok(msg),
300 };
301
302 let edges =
303 match with_graph_store_timeout(store.edge_history_for_entity(entity.id.0, 50))
304 .await?
305 {
306 StoreCallOutcome::Completed(v) => v,
307 StoreCallOutcome::TimedOut => {
308 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
309 }
310 };
311 if edges.is_empty() {
312 return Ok(format!("Entity '{}' has no edge history.", entity.name));
313 }
314
315 let entity_names = build_entity_name_map(&store, &entity, &edges).await;
316
317 let n = edges.len();
318 let lines: Vec<String> = edges
319 .iter()
320 .map(|e| {
321 let status = if e.valid_to.is_some() {
322 let date = e
323 .valid_to
324 .as_deref()
325 .and_then(|s| s.split('T').next().or_else(|| s.split(' ').next()))
326 .unwrap_or("?");
327 format!("[expired {date}]")
328 } else {
329 "[active]".to_string()
330 };
331 let src = entity_names
332 .get(&e.source_entity_id)
333 .cloned()
334 .unwrap_or_else(|| format!("#{}", e.source_entity_id));
335 let tgt = entity_names
336 .get(&e.target_entity_id)
337 .cloned()
338 .unwrap_or_else(|| format!("#{}", e.target_entity_id));
339 format!(
340 " {status} {} --[{}/{}]--> {}: {} (confidence: {:.2})",
341 src, e.relation, e.edge_type, tgt, e.fact, e.confidence
342 )
343 })
344 .collect();
345 Ok(format!(
346 "Edge history for '{}' ({n} edges):\n{}",
347 entity.name,
348 lines.join("\n")
349 ))
350 }
351 .instrument(tracing::info_span!("core.agent_access.graph_history")),
352 )
353 }
354
355 fn graph_communities<'a>(
356 &'a mut self,
357 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
358 Box::pin(
359 async move {
360 let (_, store) = match self.resolve_graph_store() {
361 Ok(pair) => pair,
362 Err(msg) => return Ok(msg),
363 };
364
365 let communities =
366 match tokio::time::timeout(Duration::from_secs(5), store.all_communities())
367 .await
368 {
369 Ok(Ok(v)) => v,
370 Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
371 Err(_) => {
372 tracing::warn!(
373 "graph store call timed out after 5s (Qdrant unreachable)"
374 );
375 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
376 }
377 };
378 if communities.is_empty() {
379 return Ok("No communities detected yet. Run graph backfill first.".to_owned());
380 }
381
382 let lines: Vec<String> = communities
383 .iter()
384 .map(|c| format!(" [{}]: {}", c.name, c.summary))
385 .collect();
386 Ok(format!(
387 "Communities ({}):\n{}",
388 communities.len(),
389 lines.join("\n")
390 ))
391 }
392 .instrument(tracing::info_span!("core.agent_access.graph_communities")),
393 )
394 }
395
396 #[allow(clippy::too_many_lines)]
397 fn graph_backfill<'a>(
398 &'a mut self,
399 limit: Option<usize>,
400 progress_cb: &'a mut (dyn FnMut(String) + Send),
401 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
402 let store = match self.resolve_graph_store() {
403 Ok((_, s)) => s,
404 Err(msg) => return Box::pin(async move { Ok(msg) }),
405 };
406 let graph_cfg = self.services.memory.extraction.graph_config.clone();
407 let embed_timeout_secs = self
408 .services
409 .memory
410 .persistence
411 .memory
412 .as_ref()
413 .map_or(5, |m| m.embed_timeout().as_secs());
414 let provider = if graph_cfg.extract_provider.as_str().is_empty() {
415 self.provider.clone()
416 } else {
417 self.resolve_background_provider(graph_cfg.extract_provider.as_str())
418 };
419 Box::pin(
420 async move {
421 let total = store.unprocessed_message_count().await.unwrap_or(0);
422 let cap = limit.unwrap_or(usize::MAX);
423
424 progress_cb(format!(
425 "Starting graph backfill... ({total} unprocessed messages)"
426 ));
427
428 let batch_size = 50usize;
429 let mut processed = 0usize;
430 let mut total_entities = 0usize;
431 let mut total_edges = 0usize;
432
433 loop {
434 let remaining_cap = cap.saturating_sub(processed);
435 if remaining_cap == 0 {
436 break;
437 }
438 let batch_limit = batch_size.min(remaining_cap);
439 let messages = store
440 .unprocessed_messages_for_backfill(batch_limit)
441 .await
442 .map_err(|e| CommandError::new(e.to_string()))?;
443 if messages.is_empty() {
444 break;
445 }
446
447 let ids: Vec<zeph_memory::types::MessageId> =
448 messages.iter().map(|(id, _)| *id).collect();
449
450 let extraction_cfg = GraphExtractionConfig {
454 max_entities: graph_cfg.max_entities_per_message,
455 max_edges: graph_cfg.max_edges_per_message,
456 extraction_timeout_secs: graph_cfg.extraction_timeout_secs,
457 community_refresh_interval: 0,
458 expired_edge_retention_days: graph_cfg.expired_edge_retention_days,
459 max_entities_cap: graph_cfg.max_entities,
460 community_summary_max_prompt_bytes: graph_cfg
461 .community_summary_max_prompt_bytes,
462 community_summary_concurrency: graph_cfg.community_summary_concurrency,
463 lpa_edge_chunk_size: graph_cfg.lpa_edge_chunk_size,
464 note_linking: zeph_memory::NoteLinkingConfig::default(),
465 link_weight_decay_lambda: graph_cfg.link_weight_decay_lambda,
466 link_weight_decay_interval_secs: graph_cfg.link_weight_decay_interval_secs,
467 belief_revision_enabled: graph_cfg.belief_revision.enabled,
468 belief_revision_similarity_threshold: graph_cfg
469 .belief_revision
470 .similarity_threshold,
471 conversation_id: None,
472 apex_mem_enabled: graph_cfg.apex_mem.enabled,
473 llm_timeout_secs: graph_cfg.llm_timeout_secs,
474 embed_timeout_secs,
475 turn_index: None,
476 write_gate_min_relevance: graph_cfg
477 .write_gate
478 .enabled
479 .then_some(graph_cfg.write_gate.min_edge_relevance),
480 benna_fast_rate: graph_cfg.spreading_activation.benna_fast_rate,
481 benna_slow_rate: graph_cfg.spreading_activation.benna_slow_rate,
482 provenance: None,
483 system_prompt: None,
484 recall_include_imported: graph_cfg.recall_include_imported,
485 };
486
487 {
497 use futures::stream::StreamExt as _;
498
499 let extraction_futs: Vec<_> = messages
500 .iter()
501 .filter_map(|(_id, content)| {
502 if content.trim().is_empty() {
503 return None;
504 }
505 let content = content.clone();
506 let provider = provider.clone();
507 let pool = store.pool().clone();
508 let extraction_cfg = extraction_cfg.clone();
509 Some(extract_and_store(
510 content,
511 vec![],
512 provider,
513 pool,
514 extraction_cfg,
515 None,
516 None,
517 ))
518 })
519 .collect();
520
521 let results: Vec<_> = futures::stream::iter(extraction_futs)
522 .buffer_unordered(4)
523 .collect()
524 .await;
525
526 for result in results {
527 match result {
528 Ok(result) => {
529 total_entities += result.stats.entities_upserted;
530 total_edges += result.stats.edges_inserted;
531 }
532 Err(e) => {
533 tracing::warn!("backfill extraction error: {e:#}");
534 }
535 }
536 }
537 }
538
539 store
540 .mark_messages_graph_processed(&ids)
541 .await
542 .map_err(|e| CommandError::new(e.to_string()))?;
543 processed += messages.len();
544
545 progress_cb(format!(
546 "Backfill progress: {processed} messages processed, \
547 {total_entities} entities, {total_edges} edges"
548 ));
549 }
550
551 Ok(format!(
552 "Backfill complete: {total_entities} entities, {total_edges} edges \
553 extracted from {processed} messages"
554 ))
555 }
556 .instrument(tracing::info_span!("core.agent_access.graph_backfill")),
557 )
558 }
559
560 fn knowledge_status<'a>(
563 &'a mut self,
564 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
565 Box::pin(
566 async move {
567 use zeph_memory::graph::ingest::IngestLedger;
568
569 let Some(memory) = self.services.memory.persistence.memory.clone() else {
570 return Ok("Memory subsystem not available.".to_owned());
571 };
572 let pool = memory.sqlite().pool().clone();
573 let ledger = IngestLedger::new(pool);
574
575 let rows = match ledger.summary().await {
576 Ok(r) => r,
577 Err(e) => return Err(CommandError(e.to_string())),
578 };
579
580 if rows.is_empty() {
581 return Ok("No knowledge has been ingested yet. \
582 Run `zeph knowledge ingest --source <src>`."
583 .to_owned());
584 }
585
586 let mut out = format!("Knowledge ingest ledger ({} entries):\n\n", rows.len());
587 let mut current_batch = String::new();
588 for row in &rows {
589 let batch_short = &row.import_batch_id[..row.import_batch_id.len().min(8)];
590 if current_batch != row.import_batch_id {
591 if !current_batch.is_empty() {
592 out.push('\n');
593 }
594 current_batch.clone_from(&row.import_batch_id);
595 }
596 let uri_display = &row.source_uri[..row.source_uri.floor_char_boundary(40)];
597 let at_display = &row.ingested_at[..row.ingested_at.len().min(19)];
598 let _ = writeln!(
599 out,
600 " {uri_display:<40} batch={batch_short} at={at_display} \
601 e={} edges={}",
602 row.entities, row.edges,
603 );
604 }
605 Ok(out.trim_end().to_owned())
606 }
607 .instrument(tracing::info_span!("core.agent_access.knowledge_status")),
608 )
609 }
610
611 fn knowledge_rollback<'a>(
612 &'a mut self,
613 batch_id: &'a str,
614 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
615 Box::pin(
616 async move {
617 use zeph_memory::graph::ingest::IngestLedger;
618
619 let Some(memory) = self.services.memory.persistence.memory.clone() else {
620 return Ok("Memory subsystem not available.".to_owned());
621 };
622 let pool = memory.sqlite().pool().clone();
623 let ledger = IngestLedger::new(pool.clone());
624
625 match ledger.batch_exists(batch_id).await {
626 Ok(false) => {
627 return Ok(format!("Batch '{batch_id}' not found in ledger."));
628 }
629 Err(e) => return Err(CommandError(e.to_string())),
630 Ok(true) => {}
631 }
632
633 let Some(graph_store) = memory.graph_store.clone() else {
634 return Ok(
635 "Graph store unavailable (Qdrant unreachable or graph not enabled)."
636 .to_owned(),
637 );
638 };
639
640 let mut tx = zeph_db::begin_write(&pool)
641 .await
642 .map_err(|e| CommandError(e.to_string()))?;
643
644 let (edges, entities) = graph_store
645 .delete_batch_in_tx(batch_id, &mut tx)
646 .await
647 .map_err(|e| CommandError(e.to_string()))?;
648 ledger
649 .delete_batch_in_tx(batch_id, &mut tx)
650 .await
651 .map_err(|e| CommandError(e.to_string()))?;
652
653 tx.commit().await.map_err(|e| CommandError(e.to_string()))?;
654
655 let mut msg = format!(
656 "Rolled back batch '{batch_id}': removed {edges} edge(s) and \
657 {entities} entity(ies)."
658 );
659 if edges == 0 && entities == 0 {
660 msg.push_str(
661 "\nNote: no graph rows found. Phase-1 ingest writes to Qdrant notes — \
662 Qdrant embeddings are NOT removed by this rollback.",
663 );
664 }
665 Ok(msg)
666 }
667 .instrument(tracing::info_span!("core.agent_access.knowledge_rollback")),
668 )
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::super::agent_tests::{
675 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
676 };
677 use super::*;
678
679 async fn memory_without_qdrant() -> SemanticMemory {
680 SemanticMemory::new(
681 ":memory:",
682 "http://127.0.0.1:1",
683 None,
684 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
685 "test-model",
686 )
687 .await
688 .unwrap()
689 }
690
691 #[tokio::test]
695 async fn graph_stats_enabled_but_no_store_reports_unavailable() {
696 let cfg = crate::config::GraphConfig {
697 enabled: true,
698 ..Default::default()
699 };
700 let memory = memory_without_qdrant().await;
701 let cid = memory.sqlite().create_conversation().await.unwrap();
702 let mut agent = Agent::new(
703 mock_provider(vec![]),
704 MockChannel::new(vec![]),
705 create_test_registry(),
706 None,
707 5,
708 MockToolExecutor::no_tools(),
709 )
710 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
711 .with_graph_config(cfg);
712
713 let result = agent.graph_stats().await.unwrap();
714 assert!(
715 result.contains("unavailable"),
716 "expected 'unavailable' but got: {result}"
717 );
718 assert!(
719 !result.contains("not enabled"),
720 "must not report 'not enabled' when graph is enabled: {result}"
721 );
722 }
723
724 #[tokio::test]
725 async fn graph_stats_disabled_reports_not_enabled() {
726 let cfg = crate::config::GraphConfig {
727 enabled: false,
728 ..Default::default()
729 };
730 let memory = memory_without_qdrant().await;
731 let cid = memory.sqlite().create_conversation().await.unwrap();
732 let mut agent = Agent::new(
733 mock_provider(vec![]),
734 MockChannel::new(vec![]),
735 create_test_registry(),
736 None,
737 5,
738 MockToolExecutor::no_tools(),
739 )
740 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
741 .with_graph_config(cfg);
742
743 let result = agent.graph_stats().await.unwrap();
744 assert!(
745 result.contains("not enabled"),
746 "expected 'not enabled' but got: {result}"
747 );
748 }
749
750 #[tokio::test]
756 async fn graph_backfill_with_extract_provider_resolves_without_panic() {
757 let cfg = crate::config::GraphConfig {
758 enabled: true,
759 extract_provider: zeph_config::providers::ProviderName::new("nonexistent-provider"),
760 ..Default::default()
761 };
762 let mut memory = memory_without_qdrant().await;
763 let pool = memory.sqlite().pool().clone();
765 memory.graph_store = Some(std::sync::Arc::new(zeph_memory::GraphStore::new(pool)));
766 let cid = memory.sqlite().create_conversation().await.unwrap();
767 let mut agent = Agent::new(
768 mock_provider(vec![]),
769 MockChannel::new(vec![]),
770 create_test_registry(),
771 None,
772 5,
773 MockToolExecutor::no_tools(),
774 )
775 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
776 .with_graph_config(cfg);
777
778 let mut progress = vec![];
779 let result = agent
780 .graph_backfill(Some(10), &mut |msg| progress.push(msg))
781 .await
782 .unwrap();
783
784 assert!(
786 result.contains("Backfill complete"),
787 "expected 'Backfill complete' but got: {result}"
788 );
789 }
790
791 #[tokio::test]
798 async fn graph_backfill_concurrent_extraction_aggregates_stats_without_dropping_results() {
799 let n = 6;
800 let cfg = crate::config::GraphConfig {
801 enabled: true,
802 ..Default::default()
803 };
804 let mut memory = memory_without_qdrant().await;
805 let store = install_graph_store(&mut memory);
806 let cid = memory.sqlite().create_conversation().await.unwrap();
807
808 for i in 0..n {
809 sqlx::query(zeph_db::sql!(
810 "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
811 ))
812 .bind(cid.0)
813 .bind(format!("message body {i}"))
814 .execute(memory.sqlite().pool())
815 .await
816 .unwrap();
817 }
818
819 let responses: Vec<String> = (0..n)
823 .map(|i| {
824 format!(
825 r#"{{"entities":[{{"name":"Entity{i}","type":"concept","summary":""}}],"edges":[]}}"#
826 )
827 })
828 .collect();
829
830 let mut agent = Agent::new(
831 mock_provider(responses),
832 MockChannel::new(vec![]),
833 create_test_registry(),
834 None,
835 5,
836 MockToolExecutor::no_tools(),
837 )
838 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
839 .with_graph_config(cfg);
840
841 let mut progress = vec![];
842 let result = agent
843 .graph_backfill(None, &mut |msg| progress.push(msg))
844 .await
845 .unwrap();
846
847 assert!(
848 result.contains(&format!("{n} entities")),
849 "expected all {n} entities aggregated in the result, got: {result}"
850 );
851 assert!(
852 result.contains(&format!("from {n} messages")),
853 "expected all {n} messages counted as processed, got: {result}"
854 );
855
856 for i in 0..n {
858 let name = format!("entity{i}");
859 let found = store
860 .find_entity(&name, zeph_memory::EntityType::Concept)
861 .await
862 .unwrap();
863 assert!(found.is_some(), "entity{i} must have been upserted");
864 }
865
866 let remaining = store.unprocessed_message_count().await.unwrap();
869 assert_eq!(remaining, 0, "all messages must be marked graph_processed");
870 }
871
872 #[tokio::test]
885 async fn graph_backfill_concurrent_extraction_survives_real_sqlite_write_contention() {
886 let n = 8;
887 let tmp = tempfile::NamedTempFile::new().expect("tempfile");
888 let path = tmp.path().to_str().expect("valid utf-8 path").to_owned();
889
890 let cfg = crate::config::GraphConfig {
891 enabled: true,
892 ..Default::default()
893 };
894 let mut memory = SemanticMemory::new(
895 &path,
896 "http://127.0.0.1:1",
897 None,
898 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
899 "test-model",
900 )
901 .await
902 .unwrap();
903 let store = install_graph_store(&mut memory);
904 let cid = memory.sqlite().create_conversation().await.unwrap();
905
906 for i in 0..n {
907 sqlx::query(zeph_db::sql!(
908 "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
909 ))
910 .bind(cid.0)
911 .bind(format!("contention message body {i}"))
912 .execute(memory.sqlite().pool())
913 .await
914 .unwrap();
915 }
916
917 let responses: Vec<String> = (0..n)
921 .map(|i| {
922 format!(
923 r#"{{"entities":[{{"name":"ContentionEntity{i}","type":"concept","summary":""}}],"edges":[]}}"#
924 )
925 })
926 .collect();
927 let mut provider = zeph_llm::mock::MockProvider::with_responses(responses);
928 provider.delay_ms = 15;
929 let provider = zeph_llm::any::AnyProvider::Mock(provider);
930
931 let mut agent = Agent::new(
932 provider,
933 MockChannel::new(vec![]),
934 create_test_registry(),
935 None,
936 5,
937 MockToolExecutor::no_tools(),
938 )
939 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
940 .with_graph_config(cfg);
941
942 let mut progress = vec![];
943 let result = agent
944 .graph_backfill(None, &mut |msg| progress.push(msg))
945 .await
946 .unwrap();
947
948 assert!(
949 result.contains(&format!("{n} entities")),
950 "expected all {n} entities aggregated despite concurrent SQLite writers, got: {result}"
951 );
952
953 for i in 0..n {
959 let name = format!("contentionentity{i}");
960 let found = store
961 .find_entity(&name, zeph_memory::EntityType::Concept)
962 .await
963 .unwrap();
964 assert!(
965 found.is_some(),
966 "entity {i} must have been upserted; a missing entity indicates a dropped/failed \
967 concurrent write (e.g. a 'database is locked' error) under real multi-connection \
968 contention"
969 );
970 }
971
972 let remaining = store.unprocessed_message_count().await.unwrap();
973 assert_eq!(remaining, 0, "all messages must be marked graph_processed");
974 }
975
976 #[tokio::test]
979 async fn graph_entities_enabled_but_no_store_reports_unavailable() {
980 let cfg = crate::config::GraphConfig {
981 enabled: true,
982 ..Default::default()
983 };
984 let memory = memory_without_qdrant().await;
985 let cid = memory.sqlite().create_conversation().await.unwrap();
986 let mut agent = Agent::new(
987 mock_provider(vec![]),
988 MockChannel::new(vec![]),
989 create_test_registry(),
990 None,
991 5,
992 MockToolExecutor::no_tools(),
993 )
994 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
995 .with_graph_config(cfg);
996
997 let result = agent.graph_entities().await.unwrap();
998 assert!(
999 result.contains("unavailable"),
1000 "expected 'unavailable' but got: {result}"
1001 );
1002 }
1003
1004 #[tokio::test]
1006 async fn graph_communities_enabled_but_no_store_reports_unavailable() {
1007 let cfg = crate::config::GraphConfig {
1008 enabled: true,
1009 ..Default::default()
1010 };
1011 let memory = memory_without_qdrant().await;
1012 let cid = memory.sqlite().create_conversation().await.unwrap();
1013 let mut agent = Agent::new(
1014 mock_provider(vec![]),
1015 MockChannel::new(vec![]),
1016 create_test_registry(),
1017 None,
1018 5,
1019 MockToolExecutor::no_tools(),
1020 )
1021 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1022 .with_graph_config(cfg);
1023
1024 let result = agent.graph_communities().await.unwrap();
1025 assert!(
1026 result.contains("unavailable"),
1027 "expected 'unavailable' but got: {result}"
1028 );
1029 }
1030
1031 #[tokio::test]
1036 async fn graph_store_timeout_pattern_fires_on_pending_future() {
1037 use std::future;
1038 let result = tokio::time::timeout(
1039 Duration::from_millis(10),
1040 future::pending::<Result<Vec<()>, String>>(),
1041 )
1042 .await;
1043 assert!(
1044 result.is_err(),
1045 "timeout must fire on a never-resolving future"
1046 );
1047 }
1048
1049 #[tokio::test]
1056 async fn with_graph_store_timeout_completes_on_success() {
1057 let result = with_graph_store_timeout(async { Ok::<_, zeph_memory::MemoryError>(42) })
1058 .await
1059 .unwrap();
1060 assert!(matches!(result, StoreCallOutcome::Completed(42)));
1061 }
1062
1063 #[tokio::test]
1064 async fn with_graph_store_timeout_maps_store_error_to_command_error() {
1065 let result = with_graph_store_timeout(async {
1066 Err::<i32, _>(zeph_memory::MemoryError::GraphStore("boom".to_owned()))
1067 })
1068 .await;
1069 assert!(result.is_err(), "store error must surface as CommandError");
1070 }
1071
1072 #[tokio::test]
1073 async fn with_graph_store_timeout_times_out_on_pending_future() {
1074 tokio::time::pause();
1075 let fut = with_graph_store_timeout(std::future::pending::<
1076 Result<i32, zeph_memory::MemoryError>,
1077 >());
1078 let handle = tokio::spawn(fut); tokio::time::advance(std::time::Duration::from_secs(6)).await;
1080 let result = handle.await.expect("task panicked");
1081 assert!(
1082 matches!(result, Ok(StoreCallOutcome::TimedOut)),
1083 "call must resolve to TimedOut once the 5s deadline elapses"
1084 );
1085 }
1086
1087 fn install_graph_store(memory: &mut SemanticMemory) -> std::sync::Arc<zeph_memory::GraphStore> {
1093 let pool = memory.sqlite().pool().clone();
1094 let store = std::sync::Arc::new(zeph_memory::GraphStore::new(pool));
1095 memory.graph_store = Some(store.clone());
1096 store
1097 }
1098
1099 #[tokio::test]
1100 async fn graph_facts_happy_path_returns_formatted_facts() {
1101 let mut memory = memory_without_qdrant().await;
1102 let store = install_graph_store(&mut memory);
1103 let cid = memory.sqlite().create_conversation().await.unwrap();
1104
1105 let alice = store
1106 .upsert_entity(
1107 "Alice",
1108 "alice",
1109 zeph_memory::EntityType::Person,
1110 None,
1111 None,
1112 )
1113 .await
1114 .unwrap();
1115 let bob = store
1116 .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
1117 .await
1118 .unwrap();
1119 store
1120 .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
1121 .await
1122 .unwrap();
1123
1124 let cfg = crate::config::GraphConfig {
1125 enabled: true,
1126 ..Default::default()
1127 };
1128 let mut agent = Agent::new(
1129 mock_provider(vec![]),
1130 MockChannel::new(vec![]),
1131 create_test_registry(),
1132 None,
1133 5,
1134 MockToolExecutor::no_tools(),
1135 )
1136 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1137 .with_graph_config(cfg);
1138
1139 let result = agent.graph_facts("Alice").await.unwrap();
1140 assert!(
1141 result.contains("Facts for 'Alice'"),
1142 "expected facts header, got: {result}"
1143 );
1144 assert!(
1145 result.contains("Bob"),
1146 "expected target entity name, got: {result}"
1147 );
1148 assert!(result.contains("knows"), "expected relation, got: {result}");
1149 assert!(
1150 result.contains("Alice knows Bob"),
1151 "expected fact text, got: {result}"
1152 );
1153 }
1154
1155 #[tokio::test]
1156 async fn graph_facts_entity_not_found_returns_message() {
1157 let mut memory = memory_without_qdrant().await;
1158 install_graph_store(&mut memory);
1159 let cid = memory.sqlite().create_conversation().await.unwrap();
1160
1161 let cfg = crate::config::GraphConfig {
1162 enabled: true,
1163 ..Default::default()
1164 };
1165 let mut agent = Agent::new(
1166 mock_provider(vec![]),
1167 MockChannel::new(vec![]),
1168 create_test_registry(),
1169 None,
1170 5,
1171 MockToolExecutor::no_tools(),
1172 )
1173 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1174 .with_graph_config(cfg);
1175
1176 let result = agent.graph_facts("Nobody").await.unwrap();
1177 assert_eq!(result, "No entity found matching 'Nobody'.");
1178 }
1179
1180 #[tokio::test]
1184 async fn graph_facts_enabled_but_no_store_reports_unavailable() {
1185 let cfg = crate::config::GraphConfig {
1186 enabled: true,
1187 ..Default::default()
1188 };
1189 let memory = memory_without_qdrant().await;
1190 let cid = memory.sqlite().create_conversation().await.unwrap();
1191 let mut agent = Agent::new(
1192 mock_provider(vec![]),
1193 MockChannel::new(vec![]),
1194 create_test_registry(),
1195 None,
1196 5,
1197 MockToolExecutor::no_tools(),
1198 )
1199 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1200 .with_graph_config(cfg);
1201
1202 let result = agent.graph_facts("Alice").await.unwrap();
1203 assert!(
1204 result.contains("unavailable"),
1205 "expected 'unavailable' but got: {result}"
1206 );
1207 }
1208
1209 #[tokio::test]
1216 async fn graph_facts_self_loop_edge_does_not_panic() {
1217 let mut memory = memory_without_qdrant().await;
1218 let store = install_graph_store(&mut memory);
1219 let cid = memory.sqlite().create_conversation().await.unwrap();
1220
1221 let self_entity = store
1222 .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
1223 .await
1224 .unwrap();
1225 let pool = memory.sqlite().pool().clone();
1226 zeph_db::query(zeph_db::sql!(
1227 "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
1228 ))
1229 .execute(&pool)
1230 .await
1231 .unwrap();
1232 zeph_db::query(zeph_db::sql!(
1233 "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
1234 VALUES (?, ?, ?, ?, ?)"
1235 ))
1236 .bind(self_entity.0)
1237 .bind(self_entity.0)
1238 .bind("refers_to")
1239 .bind("Self refers to itself")
1240 .bind(1.0_f64)
1241 .execute(&pool)
1242 .await
1243 .unwrap();
1244
1245 let cfg = crate::config::GraphConfig {
1246 enabled: true,
1247 ..Default::default()
1248 };
1249 let mut agent = Agent::new(
1250 mock_provider(vec![]),
1251 MockChannel::new(vec![]),
1252 create_test_registry(),
1253 None,
1254 5,
1255 MockToolExecutor::no_tools(),
1256 )
1257 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1258 .with_graph_config(cfg);
1259
1260 let result = agent.graph_facts("Self").await.unwrap();
1261 assert!(
1262 result.contains("Facts for 'Self'"),
1263 "expected facts header, got: {result}"
1264 );
1265 assert!(
1266 result.contains("refers_to"),
1267 "expected self-loop relation, got: {result}"
1268 );
1269 assert!(
1270 !result.contains('#'),
1271 "self-loop endpoint must resolve to the entity's own name, not a raw #id \
1272 placeholder: {result}"
1273 );
1274 }
1275
1276 #[tokio::test]
1277 async fn graph_history_happy_path_returns_formatted_history() {
1278 let mut memory = memory_without_qdrant().await;
1279 let store = install_graph_store(&mut memory);
1280 let cid = memory.sqlite().create_conversation().await.unwrap();
1281
1282 let alice = store
1283 .upsert_entity(
1284 "Alice",
1285 "alice",
1286 zeph_memory::EntityType::Person,
1287 None,
1288 None,
1289 )
1290 .await
1291 .unwrap();
1292 let bob = store
1293 .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
1294 .await
1295 .unwrap();
1296 store
1297 .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
1298 .await
1299 .unwrap();
1300
1301 let cfg = crate::config::GraphConfig {
1302 enabled: true,
1303 ..Default::default()
1304 };
1305 let mut agent = Agent::new(
1306 mock_provider(vec![]),
1307 MockChannel::new(vec![]),
1308 create_test_registry(),
1309 None,
1310 5,
1311 MockToolExecutor::no_tools(),
1312 )
1313 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1314 .with_graph_config(cfg);
1315
1316 let result = agent.graph_history("Alice").await.unwrap();
1317 assert!(
1318 result.contains("Edge history for 'Alice'"),
1319 "expected history header, got: {result}"
1320 );
1321 assert!(
1322 result.contains("[active]"),
1323 "expected active tag, got: {result}"
1324 );
1325 assert!(
1326 result.contains("Bob"),
1327 "expected target entity name, got: {result}"
1328 );
1329 }
1330
1331 #[tokio::test]
1332 async fn graph_history_entity_not_found_returns_message() {
1333 let mut memory = memory_without_qdrant().await;
1334 install_graph_store(&mut memory);
1335 let cid = memory.sqlite().create_conversation().await.unwrap();
1336
1337 let cfg = crate::config::GraphConfig {
1338 enabled: true,
1339 ..Default::default()
1340 };
1341 let mut agent = Agent::new(
1342 mock_provider(vec![]),
1343 MockChannel::new(vec![]),
1344 create_test_registry(),
1345 None,
1346 5,
1347 MockToolExecutor::no_tools(),
1348 )
1349 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1350 .with_graph_config(cfg);
1351
1352 let result = agent.graph_history("Nobody").await.unwrap();
1353 assert_eq!(result, "No entity found matching 'Nobody'.");
1354 }
1355
1356 #[tokio::test]
1357 async fn graph_history_enabled_but_no_store_reports_unavailable() {
1358 let cfg = crate::config::GraphConfig {
1359 enabled: true,
1360 ..Default::default()
1361 };
1362 let memory = memory_without_qdrant().await;
1363 let cid = memory.sqlite().create_conversation().await.unwrap();
1364 let mut agent = Agent::new(
1365 mock_provider(vec![]),
1366 MockChannel::new(vec![]),
1367 create_test_registry(),
1368 None,
1369 5,
1370 MockToolExecutor::no_tools(),
1371 )
1372 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1373 .with_graph_config(cfg);
1374
1375 let result = agent.graph_history("Alice").await.unwrap();
1376 assert!(
1377 result.contains("unavailable"),
1378 "expected 'unavailable' but got: {result}"
1379 );
1380 }
1381
1382 #[tokio::test]
1384 async fn graph_history_self_loop_edge_does_not_panic() {
1385 let mut memory = memory_without_qdrant().await;
1386 let store = install_graph_store(&mut memory);
1387 let cid = memory.sqlite().create_conversation().await.unwrap();
1388
1389 let self_entity = store
1390 .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
1391 .await
1392 .unwrap();
1393 let pool = memory.sqlite().pool().clone();
1394 zeph_db::query(zeph_db::sql!(
1395 "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
1396 ))
1397 .execute(&pool)
1398 .await
1399 .unwrap();
1400 zeph_db::query(zeph_db::sql!(
1401 "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
1402 VALUES (?, ?, ?, ?, ?)"
1403 ))
1404 .bind(self_entity.0)
1405 .bind(self_entity.0)
1406 .bind("refers_to")
1407 .bind("Self refers to itself")
1408 .bind(1.0_f64)
1409 .execute(&pool)
1410 .await
1411 .unwrap();
1412
1413 let cfg = crate::config::GraphConfig {
1414 enabled: true,
1415 ..Default::default()
1416 };
1417 let mut agent = Agent::new(
1418 mock_provider(vec![]),
1419 MockChannel::new(vec![]),
1420 create_test_registry(),
1421 None,
1422 5,
1423 MockToolExecutor::no_tools(),
1424 )
1425 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1426 .with_graph_config(cfg);
1427
1428 let result = agent.graph_history("Self").await.unwrap();
1429 assert!(
1430 result.contains("Edge history for 'Self'"),
1431 "expected history header, got: {result}"
1432 );
1433 assert!(
1434 result.contains("refers_to"),
1435 "expected self-loop relation, got: {result}"
1436 );
1437 assert!(
1438 !result.contains('#'),
1439 "self-loop endpoint must resolve to the entity's own name, not a raw #id \
1440 placeholder: {result}"
1441 );
1442 }
1443}