1use mentedb_core::memory::MemoryType;
8use mentedb_core::types::MemoryId;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, thiserror::Error)]
17pub enum LlmJudgeError {
18 #[error("LLM provider returned an error: {0}")]
19 ProviderError(String),
20 #[error("failed to parse LLM response: {0}")]
21 ParseError(String),
22 #[error("LLM returned an unexpected verdict: {0}")]
23 UnexpectedVerdict(String),
24}
25
26pub trait LlmJudge: Send + Sync {
35 fn complete(
37 &self,
38 system_prompt: &str,
39 user_prompt: &str,
40 ) -> impl std::future::Future<Output = Result<String, LlmJudgeError>> + Send;
41}
42
43impl<T: LlmJudge> LlmJudge for std::sync::Arc<T> {
44 fn complete(
45 &self,
46 system_prompt: &str,
47 user_prompt: &str,
48 ) -> impl std::future::Future<Output = Result<String, LlmJudgeError>> + Send {
49 (**self).complete(system_prompt, user_prompt)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "verdict", rename_all = "snake_case")]
60pub enum InvalidationVerdict {
61 Keep { reason: String },
63 Invalidate { reason: String },
65 Update {
67 merged_content: String,
68 reason: String,
69 },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "verdict", rename_all = "snake_case")]
75pub enum ContradictionVerdict {
76 Compatible { reason: String },
78 Contradicts { reason: String },
80 Supersedes {
82 #[serde(rename = "superseding_id")]
83 winner: String,
84 reason: String,
85 },
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct EntityMergeGroup {
91 pub canonical: String,
93 pub aliases: Vec<String>,
95 pub confidence: f32,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EntityCandidate {
102 pub name: String,
104 pub context: Option<String>,
106 pub memory_id: Option<MemoryId>,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(tag = "action", rename_all = "snake_case")]
113pub enum ConsolidationDecision {
114 KeepAll { reason: String },
116 Merge {
118 merged_content: String,
119 merged_type: String,
120 keep_ids: Vec<String>,
121 remove_ids: Vec<String>,
122 reason: String,
123 },
124 Deduplicate {
126 keep_id: String,
127 remove_ids: Vec<String>,
128 reason: String,
129 },
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct TopicLabel {
135 pub topic: String,
137 pub is_new: bool,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct MemorySummary {
148 pub id: MemoryId,
149 pub content: String,
150 pub memory_type: MemoryType,
151 pub confidence: f32,
152 pub created_at: u64,
153}
154
155mod prompts {
160 pub const INVALIDATION_SYSTEM: &str = r#"You are a memory validity judge for an AI agent's long term memory system.
161
162Given an EXISTING memory and a NEW memory, determine the relationship:
163
164- "keep": Both memories are valid. They describe different facts or the old one is still true.
165- "invalidate": The new memory makes the old one no longer true. Example: old says "Alice works at Acme", new says "Alice joined Google" — the old employment fact is now outdated.
166- "update": The old memory should be updated to incorporate new information. Example: old says "project uses React", new says "project migrated from React to Vue" — merge into one memory.
167
168Respond with ONLY a JSON object. Examples:
169{"verdict": "keep", "reason": "These describe different aspects of the same topic"}
170{"verdict": "invalidate", "reason": "The user changed jobs, old employment is outdated"}
171{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "The new memory adds temporal context to the old one"}"#;
172
173 pub const CONTRADICTION_SYSTEM: &str = r#"You are a contradiction detector for an AI agent's long term memory system.
174
175Given two memories A and B, determine their relationship:
176
177- "compatible": They can both be true at the same time.
178- "contradicts": They directly contradict each other and cannot both be true.
179- "supersedes": One replaces the other due to a change over time (not a logical contradiction but a temporal update).
180
181For "supersedes", include which memory wins using its ID.
182
183Respond with ONLY a JSON object. Examples:
184{"verdict": "compatible", "reason": "These describe different topics"}
185{"verdict": "contradicts", "reason": "Cannot prefer both PostgreSQL and MySQL as primary database"}
186{"verdict": "supersedes", "superseding_id": "<id of winner>", "reason": "Memory B reflects a more recent decision"}"#;
187
188 pub const ENTITY_RESOLUTION_SYSTEM: &str = r#"You are an entity resolution system for an AI agent's long term memory.
189
190Given a list of entity references (names, pronouns, descriptions), group the ones that refer to the same real world entity. Use the provided context to disambiguate.
191
192Rules:
193- Only group entities you are confident refer to the same thing
194- "Python" (language) and "python" (the snake) are DIFFERENT entities if context makes that clear
195- Pronouns like "my manager" can be grouped with a name if context confirms it
196- Include a confidence score (0.0 to 1.0) for each group
197
198Respond with ONLY a JSON object:
199{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#;
200
201 pub const CONSOLIDATION_SYSTEM: &str = r#"You are a memory consolidation system for an AI agent's long term memory.
202
203Given a cluster of similar memories, decide how to combine them:
204
205- "keep_all": The memories describe genuinely different facts despite being similar.
206- "merge": Multiple memories should be combined into one richer memory. Provide the merged content.
207- "deduplicate": One memory captures everything, the others are redundant. Keep the best one.
208
209When merging, preserve all important information from each memory. The merged content should be strictly better than any individual memory.
210
211Respond with ONLY a JSON object. Examples:
212{"action": "keep_all", "reason": "Each memory describes a different API endpoint"}
213{"action": "merge", "merged_content": "User prefers Rust for systems programming due to memory safety and zero cost abstractions", "merged_type": "Semantic", "keep_ids": [], "remove_ids": ["id1", "id2"], "reason": "Both memories express the same preference with complementary detail"}
214{"action": "deduplicate", "keep_id": "id1", "remove_ids": ["id2", "id3"], "reason": "id1 is the most complete version"}"#;
215
216 pub const TOPIC_SYSTEM: &str = r#"You are a topic labeler for a conversation tracking system.
217
218Given a user message and a list of existing topic labels, either:
2191. Assign the MOST appropriate existing label if the message fits
2202. Create a new 1-3 word label if no existing label fits
221
222Rules:
223- Labels should be lowercase, concise (1-3 words max)
224- Prefer existing labels when the topic clearly matches
225- "deploy to staging" and "deploy to production" are the SAME topic: "deployment"
226- "auth error" and "set up authentication" are the SAME topic: "authentication"
227- Be general enough to group related messages but specific enough to be useful
228
229Respond with ONLY a JSON object:
230{"topic": "authentication", "is_new": false}"#;
231}
232
233pub struct CognitiveLlmService<J: LlmJudge> {
242 judge: J,
243}
244
245impl<J: LlmJudge> CognitiveLlmService<J> {
246 pub fn new(judge: J) -> Self {
248 Self { judge }
249 }
250
251 pub async fn judge_invalidation(
258 &self,
259 old: &MemorySummary,
260 new: &MemorySummary,
261 ) -> Result<InvalidationVerdict, LlmJudgeError> {
262 let user_prompt = format!(
263 "EXISTING memory:\n{}\n\nNEW memory:\n{}",
264 serde_json::to_string_pretty(old).unwrap_or_default(),
265 serde_json::to_string_pretty(new).unwrap_or_default(),
266 );
267
268 let response = self
269 .judge
270 .complete(prompts::INVALIDATION_SYSTEM, &user_prompt)
271 .await?;
272 parse_json_response::<InvalidationVerdict>(&response)
273 }
274
275 pub async fn detect_contradiction(
277 &self,
278 a: &MemorySummary,
279 b: &MemorySummary,
280 ) -> Result<ContradictionVerdict, LlmJudgeError> {
281 let user_prompt = format!(
282 "Memory A:\n{}\n\nMemory B:\n{}",
283 serde_json::to_string_pretty(a).unwrap_or_default(),
284 serde_json::to_string_pretty(b).unwrap_or_default(),
285 );
286
287 let response = self
288 .judge
289 .complete(prompts::CONTRADICTION_SYSTEM, &user_prompt)
290 .await?;
291 parse_json_response::<ContradictionVerdict>(&response)
292 }
293
294 pub async fn resolve_entities(
296 &self,
297 candidates: &[EntityCandidate],
298 ) -> Result<Vec<EntityMergeGroup>, LlmJudgeError> {
299 if candidates.is_empty() {
300 return Ok(Vec::new());
301 }
302
303 let user_prompt = format!(
304 "Entity references to resolve:\n{}",
305 serde_json::to_string_pretty(candidates).unwrap_or_default(),
306 );
307
308 let response = self
309 .judge
310 .complete(prompts::ENTITY_RESOLUTION_SYSTEM, &user_prompt)
311 .await?;
312
313 #[derive(Deserialize)]
314 struct EntityResponse {
315 groups: Vec<EntityMergeGroup>,
316 }
317
318 let parsed = parse_json_response::<EntityResponse>(&response)?;
319 Ok(parsed.groups)
320 }
321
322 pub async fn consolidate(
324 &self,
325 cluster: &[ClusterMember],
326 ) -> Result<ConsolidationDecision, LlmJudgeError> {
327 if cluster.is_empty() {
328 return Ok(ConsolidationDecision::KeepAll {
329 reason: "Empty cluster".to_string(),
330 });
331 }
332
333 let user_prompt = format!(
334 "Memory cluster to consolidate:\n{}",
335 serde_json::to_string_pretty(cluster).unwrap_or_default(),
336 );
337
338 let response = self
339 .judge
340 .complete(prompts::CONSOLIDATION_SYSTEM, &user_prompt)
341 .await?;
342 parse_json_response::<ConsolidationDecision>(&response)
343 }
344
345 pub async fn canonicalize_topic(
347 &self,
348 message: &str,
349 existing_topics: &[String],
350 ) -> Result<TopicLabel, LlmJudgeError> {
351 let user_prompt = format!(
352 "User message: \"{}\"\n\nExisting topic labels: {:?}",
353 message, existing_topics,
354 );
355
356 let response = self
357 .judge
358 .complete(prompts::TOPIC_SYSTEM, &user_prompt)
359 .await?;
360 parse_json_response::<TopicLabel>(&response)
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ClusterMember {
367 pub id: String,
368 pub content: String,
369 pub memory_type: String,
370 pub confidence: f32,
371 pub created_at: u64,
372}
373
374fn parse_json_response<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, LlmJudgeError> {
379 if let Ok(v) = serde_json::from_str::<T>(raw) {
381 return Ok(v);
382 }
383
384 let trimmed = raw.trim();
385
386 let stripped = if trimmed.starts_with("```json") {
388 trimmed
389 .strip_prefix("```json")
390 .and_then(|s| s.strip_suffix("```"))
391 .unwrap_or(trimmed)
392 .trim()
393 } else if trimmed.starts_with("```") {
394 trimmed
395 .strip_prefix("```")
396 .and_then(|s| s.strip_suffix("```"))
397 .unwrap_or(trimmed)
398 .trim()
399 } else {
400 trimmed
401 };
402
403 if let Ok(v) = serde_json::from_str::<T>(stripped) {
404 return Ok(v);
405 }
406
407 if let Some(start) = stripped.find('{')
410 && let Some(end) = rfind_matching_brace(stripped, start)
411 {
412 let candidate = &stripped[start..=end];
413 if let Ok(v) = serde_json::from_str::<T>(candidate) {
414 return Ok(v);
415 }
416 }
417
418 Err(LlmJudgeError::ParseError(format!(
419 "could not parse LLM response as expected type. Raw response: {raw}"
420 )))
421}
422
423fn rfind_matching_brace(s: &str, start: usize) -> Option<usize> {
425 let mut depth = 0i32;
426 let mut in_string = false;
427 let mut escape_next = false;
428
429 for (i, ch) in s[start..].char_indices() {
430 if escape_next {
431 escape_next = false;
432 continue;
433 }
434 match ch {
435 '\\' if in_string => escape_next = true,
436 '"' => in_string = !in_string,
437 '{' if !in_string => depth += 1,
438 '}' if !in_string => {
439 depth -= 1;
440 if depth == 0 {
441 return Some(start + i);
442 }
443 }
444 _ => {}
445 }
446 }
447 None
448}
449
450pub struct MockLlmJudge {
456 response: String,
457}
458
459impl MockLlmJudge {
460 pub fn new(response: impl Into<String>) -> Self {
461 Self {
462 response: response.into(),
463 }
464 }
465}
466
467impl LlmJudge for MockLlmJudge {
468 async fn complete(
469 &self,
470 _system_prompt: &str,
471 _user_prompt: &str,
472 ) -> Result<String, LlmJudgeError> {
473 Ok(self.response.clone())
474 }
475}
476
477#[cfg(test)]
479pub struct CapturingJudge {
480 response: String,
481 captured: std::sync::Mutex<Vec<(String, String)>>,
482}
483
484#[cfg(test)]
485impl CapturingJudge {
486 pub fn new(response: impl Into<String>) -> Self {
487 Self {
488 response: response.into(),
489 captured: std::sync::Mutex::new(Vec::new()),
490 }
491 }
492
493 pub fn calls(&self) -> Vec<(String, String)> {
494 self.captured.lock().unwrap().clone()
495 }
496}
497
498#[cfg(test)]
499impl LlmJudge for CapturingJudge {
500 async fn complete(
501 &self,
502 system_prompt: &str,
503 user_prompt: &str,
504 ) -> Result<String, LlmJudgeError> {
505 self.captured
506 .lock()
507 .unwrap()
508 .push((system_prompt.to_string(), user_prompt.to_string()));
509 Ok(self.response.clone())
510 }
511}
512
513#[cfg(test)]
518mod tests {
519 use super::*;
520 use mentedb_core::types::MemoryId;
521
522 fn test_id() -> MemoryId {
523 MemoryId::new()
524 }
525
526 fn mem(content: &str) -> MemorySummary {
527 MemorySummary {
528 id: test_id(),
529 content: content.to_string(),
530 memory_type: MemoryType::Semantic,
531 confidence: 0.9,
532 created_at: 1000,
533 }
534 }
535
536 #[allow(dead_code)]
537 fn mem_at(content: &str, created_at: u64) -> MemorySummary {
538 MemorySummary {
539 id: test_id(),
540 content: content.to_string(),
541 memory_type: MemoryType::Semantic,
542 confidence: 0.9,
543 created_at,
544 }
545 }
546
547 #[tokio::test]
548 async fn test_judge_invalidation_keep() {
549 let judge = MockLlmJudge::new(
550 r#"{"verdict": "keep", "reason": "These describe different people"}"#,
551 );
552 let svc = CognitiveLlmService::new(judge);
553
554 let result = svc
555 .judge_invalidation(&mem("Alice works at Acme"), &mem("Bob works at Google"))
556 .await
557 .unwrap();
558
559 assert!(matches!(result, InvalidationVerdict::Keep { .. }));
560 }
561
562 #[tokio::test]
563 async fn test_judge_invalidation_invalidate() {
564 let judge =
565 MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
566 let svc = CognitiveLlmService::new(judge);
567
568 let result = svc
569 .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
570 .await
571 .unwrap();
572
573 assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
574 }
575
576 #[tokio::test]
577 async fn test_judge_invalidation_update() {
578 let judge = MockLlmJudge::new(
579 r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "Temporal update"}"#,
580 );
581 let svc = CognitiveLlmService::new(judge);
582
583 let result = svc
584 .judge_invalidation(&mem("Project uses React"), &mem("Project migrated to Vue"))
585 .await
586 .unwrap();
587
588 match result {
589 InvalidationVerdict::Update { merged_content, .. } => {
590 assert!(merged_content.contains("React"));
591 assert!(merged_content.contains("Vue"));
592 }
593 other => panic!("Expected Update, got {:?}", other),
594 }
595 }
596
597 #[tokio::test]
598 async fn test_detect_contradiction_compatible() {
599 let judge = MockLlmJudge::new(
600 r#"{"verdict": "compatible", "reason": "Different topics entirely"}"#,
601 );
602 let svc = CognitiveLlmService::new(judge);
603
604 let result = svc
605 .detect_contradiction(&mem("Likes Python"), &mem("Uses PostgreSQL"))
606 .await
607 .unwrap();
608
609 assert!(matches!(result, ContradictionVerdict::Compatible { .. }));
610 }
611
612 #[tokio::test]
613 async fn test_detect_contradiction_contradicts() {
614 let judge =
615 MockLlmJudge::new(r#"{"verdict": "contradicts", "reason": "Cannot prefer both"}"#);
616 let svc = CognitiveLlmService::new(judge);
617
618 let result = svc
619 .detect_contradiction(&mem("Prefers tabs"), &mem("Prefers spaces"))
620 .await
621 .unwrap();
622
623 assert!(matches!(result, ContradictionVerdict::Contradicts { .. }));
624 }
625
626 #[tokio::test]
627 async fn test_detect_contradiction_supersedes() {
628 let id_b = test_id();
629 let response = format!(
630 r#"{{"verdict": "supersedes", "superseding_id": "{}", "reason": "B is newer"}}"#,
631 id_b
632 );
633 let judge = MockLlmJudge::new(response);
634 let svc = CognitiveLlmService::new(judge);
635
636 let mem_b = MemorySummary {
637 id: id_b,
638 content: "Migrated to Vue".into(),
639 memory_type: MemoryType::Semantic,
640 confidence: 0.9,
641 created_at: 2000,
642 };
643
644 let result = svc
645 .detect_contradiction(&mem("Uses React"), &mem_b)
646 .await
647 .unwrap();
648
649 assert!(matches!(result, ContradictionVerdict::Supersedes { .. }));
650 }
651
652 #[tokio::test]
653 async fn test_resolve_entities() {
654 let judge = MockLlmJudge::new(
655 r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.92}]}"#,
656 );
657 let svc = CognitiveLlmService::new(judge);
658
659 let candidates = vec![
660 EntityCandidate {
661 name: "Alice".into(),
662 context: None,
663 memory_id: None,
664 },
665 EntityCandidate {
666 name: "my manager".into(),
667 context: Some("my manager Alice told me".into()),
668 memory_id: None,
669 },
670 EntityCandidate {
671 name: "Alice Smith".into(),
672 context: None,
673 memory_id: None,
674 },
675 ];
676
677 let groups = svc.resolve_entities(&candidates).await.unwrap();
678 assert_eq!(groups.len(), 1);
679 assert_eq!(groups[0].canonical, "Alice Smith");
680 assert_eq!(groups[0].aliases.len(), 2);
681 }
682
683 #[tokio::test]
684 async fn test_resolve_entities_empty() {
685 let judge = MockLlmJudge::new("should not be called");
686 let svc = CognitiveLlmService::new(judge);
687
688 let groups = svc.resolve_entities(&[]).await.unwrap();
689 assert!(groups.is_empty());
690 }
691
692 #[tokio::test]
693 async fn test_consolidate_merge() {
694 let judge = MockLlmJudge::new(
695 r#"{"action": "merge", "merged_content": "User prefers Rust for systems programming due to safety and performance", "merged_type": "Semantic", "keep_ids": [], "remove_ids": ["a", "b"], "reason": "Complementary details"}"#,
696 );
697 let svc = CognitiveLlmService::new(judge);
698
699 let cluster = vec![
700 ClusterMember {
701 id: "a".into(),
702 content: "Uses Rust".into(),
703 memory_type: "Semantic".into(),
704 confidence: 0.9,
705 created_at: 1000,
706 },
707 ClusterMember {
708 id: "b".into(),
709 content: "Prefers Rust for safety".into(),
710 memory_type: "Semantic".into(),
711 confidence: 0.85,
712 created_at: 2000,
713 },
714 ];
715
716 let decision = svc.consolidate(&cluster).await.unwrap();
717 match decision {
718 ConsolidationDecision::Merge {
719 merged_content,
720 remove_ids,
721 ..
722 } => {
723 assert!(merged_content.contains("Rust"));
724 assert_eq!(remove_ids.len(), 2);
725 }
726 other => panic!("Expected Merge, got {:?}", other),
727 }
728 }
729
730 #[tokio::test]
731 async fn test_consolidate_empty_cluster() {
732 let judge = MockLlmJudge::new("should not be called");
733 let svc = CognitiveLlmService::new(judge);
734
735 let decision = svc.consolidate(&[]).await.unwrap();
736 assert!(matches!(decision, ConsolidationDecision::KeepAll { .. }));
737 }
738
739 #[tokio::test]
740 async fn test_canonicalize_topic_existing() {
741 let judge = MockLlmJudge::new(r#"{"topic": "authentication", "is_new": false}"#);
742 let svc = CognitiveLlmService::new(judge);
743
744 let label = svc
745 .canonicalize_topic(
746 "how do I configure the auth middleware",
747 &[
748 "database".into(),
749 "authentication".into(),
750 "deployment".into(),
751 ],
752 )
753 .await
754 .unwrap();
755
756 assert_eq!(label.topic, "authentication");
757 assert!(!label.is_new);
758 }
759
760 #[tokio::test]
761 async fn test_canonicalize_topic_new() {
762 let judge = MockLlmJudge::new(r#"{"topic": "caching", "is_new": true}"#);
763 let svc = CognitiveLlmService::new(judge);
764
765 let label = svc
766 .canonicalize_topic(
767 "should we add Redis for caching",
768 &["database".into(), "authentication".into()],
769 )
770 .await
771 .unwrap();
772
773 assert_eq!(label.topic, "caching");
774 assert!(label.is_new);
775 }
776
777 #[tokio::test]
778 async fn test_json_markdown_code_block_stripping() {
779 let judge = MockLlmJudge::new(
780 "```json\n{\"verdict\": \"keep\", \"reason\": \"wrapped in markdown\"}\n```",
781 );
782 let svc = CognitiveLlmService::new(judge);
783
784 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
785
786 assert!(matches!(result, InvalidationVerdict::Keep { .. }));
787 }
788
789 #[tokio::test]
790 async fn test_json_surrounded_by_text() {
791 let judge = MockLlmJudge::new(
792 "Here is my analysis:\n{\"verdict\": \"invalidate\", \"reason\": \"job changed\"}\nLet me know if you need more detail.",
793 );
794 let svc = CognitiveLlmService::new(judge);
795
796 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
797
798 assert!(matches!(result, InvalidationVerdict::Invalidate { .. }));
799 }
800
801 #[tokio::test]
802 async fn test_json_with_nested_braces_in_strings() {
803 let judge = MockLlmJudge::new(
804 "Sure! {\"verdict\": \"update\", \"merged_content\": \"uses {curly} braces\", \"reason\": \"test\"}",
805 );
806 let svc = CognitiveLlmService::new(judge);
807
808 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await.unwrap();
809
810 match result {
811 InvalidationVerdict::Update { merged_content, .. } => {
812 assert!(merged_content.contains("{curly}"))
813 }
814 other => panic!("Expected Update, got {:?}", other),
815 }
816 }
817
818 #[tokio::test]
819 async fn test_provider_error_propagates() {
820 struct FailingJudge;
821 impl LlmJudge for FailingJudge {
822 async fn complete(&self, _: &str, _: &str) -> Result<String, LlmJudgeError> {
823 Err(LlmJudgeError::ProviderError("connection refused".into()))
824 }
825 }
826
827 let svc = CognitiveLlmService::new(FailingJudge);
828 let result = svc.judge_invalidation(&mem("A"), &mem("B")).await;
829
830 assert!(result.is_err());
831 assert!(
832 result
833 .unwrap_err()
834 .to_string()
835 .contains("connection refused")
836 );
837 }
838
839 #[tokio::test]
840 async fn test_prompt_includes_memory_content() {
841 let judge = std::sync::Arc::new(CapturingJudge::new(
842 r#"{"verdict": "keep", "reason": "test"}"#,
843 ));
844 let svc = CognitiveLlmService::new(judge.clone());
845
846 let _ = svc
847 .judge_invalidation(&mem("Alice works at Acme"), &mem("Alice joined Google"))
848 .await;
849
850 let calls = judge.calls();
851 assert_eq!(calls.len(), 1);
852 let (system, user) = &calls[0];
853 assert!(system.contains("memory validity judge"));
854 assert!(user.contains("Alice works at Acme"));
855 assert!(user.contains("Alice joined Google"));
856 }
857}