1use crate::runtime::AgentRuntime;
52use crate::runtime_ref::downcast_runtime_ref;
53use crate::templates::TemplateEngine;
54use crate::types::{
55 Entity, GenerateTextParams, Memory, MemoryQuery, ModelHandlerParams, ModelType, Relationship,
56 Role, Room, State, UUID,
57};
58use crate::utils::string_to_uuid;
59use crate::ZoeyError;
60use crate::Result;
61use serde::{Deserialize, Serialize};
62use std::collections::HashMap;
63use std::sync::{Arc, RwLock};
64use std::time::{Duration, Instant};
65use tracing::{debug, error, info, instrument, warn};
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub(crate) struct EntityResolution {
71 entity_id: Option<UUID>,
73
74 #[serde(rename = "type")]
76 match_type: MatchType,
77
78 matches: Vec<EntityMatch>,
80
81 #[serde(default)]
83 confidence: f32,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub(crate) struct EntityMatch {
90 name: String,
92
93 reason: String,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
98 entity_id: Option<UUID>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
104pub(crate) enum MatchType {
105 ExactMatch,
107 UsernameMatch,
109 NameMatch,
111 RelationshipMatch,
113 Ambiguous,
115 Unknown,
117}
118
119#[derive(Debug, Clone)]
121pub struct EntityResolutionConfig {
122 pub use_llm: bool,
124
125 pub model_type: ModelType,
127
128 pub cache_ttl: u64,
130
131 pub max_entities: usize,
133
134 pub context_message_count: usize,
136
137 pub min_confidence: f32,
139
140 pub enable_metrics: bool,
142}
143
144impl Default for EntityResolutionConfig {
145 fn default() -> Self {
146 Self {
147 use_llm: true,
148 model_type: ModelType::TextSmall,
149 cache_ttl: 300,
150 max_entities: 50,
151 context_message_count: 20,
152 min_confidence: 0.5,
153 enable_metrics: true,
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
160pub(crate) struct CacheEntry {
161 pub(crate) entity: Option<Entity>,
162 pub(crate) timestamp: Instant,
163 pub(crate) confidence: f32,
164}
165
166type EntityCache = Arc<RwLock<HashMap<String, CacheEntry>>>;
168
169static ENTITY_CACHE: once_cell::sync::Lazy<EntityCache> =
171 once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(HashMap::new())));
172
173const ENTITY_RESOLUTION_TEMPLATE: &str = r#"# Task: Resolve Entity Name
175Message Sender: {{senderName}} (ID: {{senderId}})
176Agent: {{agentName}} (ID: {{agentId}})
177
178# Entities in Room:
179{{#if entitiesInRoom}}
180{{entitiesInRoom}}
181{{/if}}
182
183{{recentMessages}}
184
185# Instructions:
1861. Analyze the context to identify which entity is being referenced
1872. Consider special references like "me" (the message sender) or "you" (agent the message is directed to)
1883. Look for usernames/handles in standard formats (e.g. @username, user#1234)
1894. Consider context from recent messages for pronouns and references
1905. If multiple matches exist, use context to disambiguate
1916. Consider recent interactions and relationship strength when resolving ambiguity
192
193Do NOT include any thinking, reasoning, or <think> sections in your response.
194Go directly to the XML response format without any preamble or explanation.
195
196Return an XML response with:
197<response>
198 <entityId>exact-id-if-known-otherwise-null</entityId>
199 <type>EXACT_MATCH | USERNAME_MATCH | NAME_MATCH | RELATIONSHIP_MATCH | AMBIGUOUS | UNKNOWN</type>
200 <matches>
201 <match>
202 <name>matched-name</name>
203 <reason>why this entity matches</reason>
204 </match>
205 </matches>
206</response>
207
208IMPORTANT: Your response must ONLY contain the <response></response> XML block above. Do not include any text, thinking, or reasoning before or after this XML block. Start your response immediately with <response> and end with </response>."#;
209
210#[instrument(skip(xml), level = "debug")]
212pub(crate) fn parse_entity_resolution_xml(xml: &str) -> Result<EntityResolution> {
213 debug!("Parsing entity resolution XML");
214
215 let entity_id = extract_xml_tag(xml, "entityId");
217
218 let match_type_str = extract_xml_tag(xml, "type").unwrap_or_else(|| "UNKNOWN".to_string());
220 let match_type = match match_type_str.to_uppercase().as_str() {
221 "EXACT_MATCH" => MatchType::ExactMatch,
222 "USERNAME_MATCH" => MatchType::UsernameMatch,
223 "NAME_MATCH" => MatchType::NameMatch,
224 "RELATIONSHIP_MATCH" => MatchType::RelationshipMatch,
225 "AMBIGUOUS" => MatchType::Ambiguous,
226 _ => MatchType::Unknown,
227 };
228
229 let confidence = extract_xml_tag(xml, "confidence")
231 .and_then(|c| c.parse::<f32>().ok())
232 .unwrap_or(0.5);
233
234 let mut matches = Vec::new();
236 if let Some(matches_section) = extract_xml_section(xml, "matches") {
237 let match_blocks = extract_xml_sections(&matches_section, "match");
238 for block in match_blocks {
239 if let (Some(name), Some(reason)) = (
240 extract_xml_tag(&block, "name"),
241 extract_xml_tag(&block, "reason"),
242 ) {
243 let entity_id = extract_xml_tag(&block, "entityId")
244 .and_then(|id| uuid::Uuid::parse_str(&id).ok());
245
246 matches.push(EntityMatch {
247 name,
248 reason,
249 entity_id,
250 });
251 }
252 }
253 }
254
255 let entity_id = entity_id.and_then(|id| {
257 if id == "null" || id.is_empty() {
258 None
259 } else {
260 match uuid::Uuid::parse_str(&id) {
261 Ok(uuid) => Some(uuid),
262 Err(e) => {
263 warn!("Failed to parse entity ID '{}': {}", id, e);
264 None
265 }
266 }
267 }
268 });
269
270 debug!(
271 "Parsed resolution: match_type={:?}, confidence={}, matches={}",
272 match_type,
273 confidence,
274 matches.len()
275 );
276
277 Ok(EntityResolution {
278 entity_id,
279 match_type,
280 matches,
281 confidence,
282 })
283}
284
285pub(crate) fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
287 let start_tag = format!("<{}>", tag);
288 let end_tag = format!("</{}>", tag);
289
290 if let Some(start_pos) = xml.find(&start_tag) {
291 let content_start = start_pos + start_tag.len();
292 if let Some(end_pos) = xml[content_start..].find(&end_tag) {
293 return Some(
294 xml[content_start..content_start + end_pos]
295 .trim()
296 .to_string(),
297 );
298 }
299 }
300 None
301}
302
303pub(crate) fn extract_xml_section(xml: &str, tag: &str) -> Option<String> {
305 let start_tag = format!("<{}>", tag);
306 let end_tag = format!("</{}>", tag);
307
308 if let Some(start_pos) = xml.find(&start_tag) {
309 let content_start = start_pos + start_tag.len();
310 if let Some(end_pos) = xml[content_start..].find(&end_tag) {
311 return Some(xml[content_start..content_start + end_pos].to_string());
312 }
313 }
314 None
315}
316
317pub(crate) fn extract_xml_sections(xml: &str, tag: &str) -> Vec<String> {
319 let mut sections = Vec::new();
320 let start_tag = format!("<{}>", tag);
321 let end_tag = format!("</{}>", tag);
322
323 let mut search_pos = 0;
324 while let Some(start_pos) = xml[search_pos..].find(&start_tag) {
325 let actual_start = search_pos + start_pos;
326 let content_start = actual_start + start_tag.len();
327
328 if let Some(end_pos) = xml[content_start..].find(&end_tag) {
329 sections.push(xml[content_start..content_start + end_pos].to_string());
330 search_pos = content_start + end_pos + end_tag.len();
331 } else {
332 break;
333 }
334 }
335
336 sections
337}
338
339pub(crate) fn generate_cache_key(message: &Memory, state: &State) -> String {
341 let entity_name = state
343 .values
344 .get("entityName")
345 .map(|s| s.as_str())
346 .unwrap_or("");
347 format!(
348 "{}:{}:{}:{}",
349 message.room_id,
350 message.entity_id,
351 message.content.text.chars().take(50).collect::<String>(),
352 entity_name
353 )
354}
355
356pub(crate) fn clean_cache(cache: &EntityCache, ttl_seconds: u64) {
358 let mut cache_lock = cache.write().unwrap();
359 let now = Instant::now();
360 let ttl = Duration::from_secs(ttl_seconds);
361
362 cache_lock.retain(|_, entry| now.duration_since(entry.timestamp) < ttl);
363}
364
365pub(crate) fn get_cached_entity(
367 cache_key: &str,
368 config: &EntityResolutionConfig,
369) -> Option<Option<Entity>> {
370 if config.cache_ttl == 0 {
371 return None;
372 }
373
374 let cache = ENTITY_CACHE.read().unwrap();
375 if let Some(entry) = cache.get(cache_key) {
376 if entry.timestamp.elapsed().as_secs() < config.cache_ttl {
378 if entry.confidence >= config.min_confidence {
380 debug!(
381 "Cache hit for entity resolution (confidence: {})",
382 entry.confidence
383 );
384 return Some(entry.entity.clone());
385 } else {
386 debug!("Cache entry found but below confidence threshold");
387 }
388 } else {
389 debug!("Cache entry expired");
390 }
391 }
392
393 None
394}
395
396pub(crate) fn cache_entity(
398 cache_key: String,
399 entity: Option<Entity>,
400 confidence: f32,
401 config: &EntityResolutionConfig,
402) {
403 if config.cache_ttl == 0 {
404 return;
405 }
406
407 let mut cache = ENTITY_CACHE.write().unwrap();
408 cache.insert(
409 cache_key,
410 CacheEntry {
411 entity,
412 timestamp: Instant::now(),
413 confidence,
414 },
415 );
416
417 debug!("Cached entity resolution (confidence: {})", confidence);
418}
419
420async fn call_llm_for_entity_resolution(
422 agent_runtime: &AgentRuntime,
423 prompt: &str,
424 model_type: ModelType,
425) -> Result<String> {
426 let models = agent_runtime.models.read().unwrap();
428
429 let model_type_str = match model_type {
430 ModelType::TextSmall => "TEXT_SMALL",
431 ModelType::TextMedium => "TEXT_MEDIUM",
432 ModelType::TextLarge => "TEXT_LARGE",
433 _ => "TEXT_SMALL", };
435
436 let handlers = models.get(model_type_str);
437
438 if let Some(handlers) = handlers {
439 if handlers.is_empty() {
440 warn!("No model handlers registered for {}", model_type_str);
441 return Err(ZoeyError::Model(format!(
442 "No model handlers for {}",
443 model_type_str
444 )));
445 }
446
447 let provider = &handlers[0];
449 info!(
450 "Using LLM provider for entity resolution: {} (priority: {})",
451 provider.name, provider.priority
452 );
453
454 let (model_name, temperature, max_tokens) = {
456 let model = if provider.name.to_lowercase().contains("openai") {
457 agent_runtime
458 .get_setting("OPENAI_MODEL")
459 .and_then(|v| v.as_str().map(|s| s.to_string()))
460 } else if provider.name.to_lowercase().contains("anthropic")
461 || provider.name.to_lowercase().contains("claude")
462 {
463 agent_runtime
464 .get_setting("ANTHROPIC_MODEL")
465 .and_then(|v| v.as_str().map(|s| s.to_string()))
466 } else {
467 agent_runtime
468 .get_setting("LOCAL_LLM_MODEL")
469 .and_then(|v| v.as_str().map(|s| s.to_string()))
470 };
471
472 let temp = agent_runtime
473 .get_setting("temperature")
474 .and_then(|v| v.as_f64().map(|f| f as f32))
475 .unwrap_or(0.3); let tokens = agent_runtime
478 .get_setting("max_tokens")
479 .and_then(|v| v.as_u64().map(|u| u as usize))
480 .unwrap_or(300); (model, temp, tokens)
483 };
484
485 let params = GenerateTextParams {
487 prompt: prompt.to_string(),
488 max_tokens: Some(max_tokens),
489 temperature: Some(temperature),
490 top_p: None,
491 stop: Some(vec!["</response>".to_string()]),
492 model: model_name,
493 frequency_penalty: None,
494 presence_penalty: None,
495 };
496
497 let model_params = ModelHandlerParams {
498 runtime: Arc::new(()) as Arc<dyn std::any::Any + Send + Sync>,
499 params,
500 };
501
502 debug!(
503 "Calling LLM for entity resolution (temp: {}, max_tokens: {})",
504 temperature, max_tokens
505 );
506
507 match (provider.handler)(model_params).await {
509 Ok(response) => {
510 info!(
511 "✓ LLM entity resolution response received ({} chars)",
512 response.len()
513 );
514 Ok(response)
515 }
516 Err(e) => {
517 error!("LLM model handler failed: {}", e);
518 Err(e)
519 }
520 }
521 } else {
522 warn!("No model handlers found for {}", model_type_str);
523 Err(ZoeyError::Model(format!(
524 "No model handlers for {}",
525 model_type_str
526 )))
527 }
528}
529
530pub async fn find_entity_by_name(
535 runtime: Arc<dyn std::any::Any + Send + Sync>,
536 message: &Memory,
537 state: &State,
538) -> Result<Option<Entity>> {
539 find_entity_by_name_with_config(runtime, message, state, &EntityResolutionConfig::default())
540 .await
541}
542
543#[instrument(skip(runtime, message, state, config), fields(
567 message_id = %message.id,
568 room_id = %message.room_id,
569 entity_id = %message.entity_id
570), level = "info")]
571pub async fn find_entity_by_name_with_config(
572 runtime: Arc<dyn std::any::Any + Send + Sync>,
573 message: &Memory,
574 state: &State,
575 config: &EntityResolutionConfig,
576) -> Result<Option<Entity>> {
577 let start_time = Instant::now();
578 info!("Starting entity resolution");
579
580 clean_cache(&ENTITY_CACHE, config.cache_ttl);
582
583 let cache_key = generate_cache_key(message, state);
585 if let Some(cached) = get_cached_entity(&cache_key, config) {
586 info!(
587 "Entity resolution cache hit ({}ms)",
588 start_time.elapsed().as_millis()
589 );
590 return Ok(cached);
591 }
592
593 debug!("Cache miss, proceeding with resolution");
594
595 let runtime_arc = if let Some(runtime_ref) = downcast_runtime_ref(&runtime) {
597 runtime_ref.try_upgrade().ok_or_else(|| {
598 error!("Runtime has been dropped");
599 ZoeyError::Runtime("Runtime has been dropped".to_string())
600 })?
601 } else {
602 error!("Runtime must be passed as Arc<RuntimeRef>");
605 return Err(ZoeyError::Runtime(
606 "Runtime must be passed as Arc<RuntimeRef>. Use RuntimeRef::new() to wrap the runtime."
607 .to_string(),
608 ));
609 };
610
611 let agent_runtime = runtime_arc.read().map_err(|e| {
613 error!("Failed to lock runtime: {}", e);
614 ZoeyError::Runtime(format!("Failed to lock runtime: {}", e))
615 })?;
616
617 let adapter_lock = agent_runtime.adapter.read().map_err(|e| {
619 error!("Failed to lock adapter: {}", e);
620 ZoeyError::Runtime(format!("Failed to lock adapter: {}", e))
621 })?;
622
623 let adapter = adapter_lock.as_ref().ok_or_else(|| {
624 warn!("No database adapter configured");
625 ZoeyError::Database("No database adapter configured".to_string())
626 })?;
627
628 let agent_id = agent_runtime.agent_id;
630 let agent_name = agent_runtime.character.name.clone();
631
632 debug!("Resolving entity for agent: {} ({})", agent_name, agent_id);
633
634 let room = if let Some(room_value) = state.data.get("room") {
636 match serde_json::from_value::<Room>(room_value.clone()) {
637 Ok(r) => {
638 debug!("Using room from state");
639 Some(r)
640 }
641 Err(e) => {
642 warn!("Failed to deserialize room from state: {}", e);
643 None
644 }
645 }
646 } else {
647 None
648 };
649
650 let room = if let Some(r) = room {
651 r
652 } else {
653 debug!("Fetching room from database: {}", message.room_id);
655 adapter.get_room(message.room_id).await?.ok_or_else(|| {
656 error!("Room not found: {}", message.room_id);
657 ZoeyError::NotFound(format!("Room {} not found", message.room_id))
658 })?
659 };
660
661 debug!("Room: {} (world: {})", room.name, room.world_id);
662
663 let world = match adapter.get_world(room.world_id).await {
665 Ok(Some(w)) => {
666 debug!("Loaded world: {}", w.name);
667 Some(w)
668 }
669 Ok(None) => {
670 debug!("World not found: {}", room.world_id);
671 None
672 }
673 Err(e) => {
674 warn!("Failed to load world: {}", e);
675 None
676 }
677 };
678
679 let entities_in_room = match adapter.get_entities_for_room(room.id, true).await {
681 Ok(entities) => {
682 debug!("Found {} entities in room", entities.len());
683 if entities.len() > config.max_entities {
685 warn!(
686 "Too many entities ({}), limiting to {}",
687 entities.len(),
688 config.max_entities
689 );
690 entities.into_iter().take(config.max_entities).collect()
691 } else {
692 entities
693 }
694 }
695 Err(e) => {
696 error!("Failed to get entities for room: {}", e);
697 return Err(e);
698 }
699 };
700
701 if let Some(ref world) = world {
703 let _world_roles: HashMap<UUID, Role> =
704 if let Some(roles_value) = world.metadata.get("roles") {
705 if let Some(roles_obj) = roles_value.as_object() {
706 roles_obj
707 .iter()
708 .filter_map(|(k, v)| {
709 let uuid = uuid::Uuid::parse_str(k).ok()?;
710 let role_str = v.as_str()?;
711 let role = match role_str.to_uppercase().as_str() {
712 "OWNER" => Role::Owner,
713 "ADMIN" => Role::Admin,
714 "MODERATOR" | "MOD" => Role::Moderator,
715 "MEMBER" => Role::Member,
716 _ => Role::None,
717 };
718 Some((uuid, role))
719 })
720 .collect()
721 } else {
722 HashMap::new()
723 }
724 } else {
725 HashMap::new()
726 };
727
728 debug!(
736 "Loaded {} entities with permission filtering",
737 entities_in_room.len()
738 );
739 }
740
741 let relationships: Vec<Relationship> = vec![]; let engine = TemplateEngine::new();
750
751 let mut template_data: HashMap<String, serde_json::Value> = HashMap::new();
752
753 let sender_entity = entities_in_room.iter().find(|e| e.id == message.entity_id);
755
756 let sender_name = sender_entity
757 .and_then(|e| e.name.clone())
758 .or_else(|| sender_entity.and_then(|e| e.username.clone()))
759 .unwrap_or_else(|| "Unknown".to_string());
760
761 template_data.insert("senderName".to_string(), serde_json::json!(sender_name));
762 template_data.insert(
763 "senderId".to_string(),
764 serde_json::json!(message.entity_id.to_string()),
765 );
766 template_data.insert("agentName".to_string(), serde_json::json!(agent_name));
767 template_data.insert(
768 "agentId".to_string(),
769 serde_json::json!(agent_id.to_string()),
770 );
771
772 let entities_str = format_entities(&entities_in_room);
774 template_data.insert(
775 "entitiesInRoom".to_string(),
776 serde_json::json!(entities_str),
777 );
778
779 let recent_messages = adapter
781 .get_memories(MemoryQuery {
782 room_id: Some(message.room_id),
783 agent_id: Some(agent_id),
784 count: Some(20),
785 unique: Some(false),
786 ..Default::default()
787 })
788 .await
789 .unwrap_or_default();
790
791 let messages_str = recent_messages
793 .iter()
794 .map(|m| {
795 let entity_name = entities_in_room
796 .iter()
797 .find(|e| e.id == m.entity_id)
798 .and_then(|e| e.name.clone())
799 .or_else(|| {
800 entities_in_room
801 .iter()
802 .find(|e| e.id == m.entity_id)
803 .and_then(|e| e.username.clone())
804 })
805 .unwrap_or_else(|| "Unknown".to_string());
806
807 format!("{}: {}", entity_name, m.content.text)
808 })
809 .collect::<Vec<_>>()
810 .join("\n");
811
812 template_data.insert(
813 "recentMessages".to_string(),
814 serde_json::json!(messages_str),
815 );
816
817 let prompt = engine.render(ENTITY_RESOLUTION_TEMPLATE, &template_data)?;
819 debug!(
820 "Generated entity resolution prompt ({} chars)",
821 prompt.len()
822 );
823
824 let (resolved_entity, confidence): (Option<Entity>, f32) = if config.use_llm {
826 debug!("Attempting LLM-based entity resolution");
827
828 match call_llm_for_entity_resolution(&agent_runtime, &prompt, config.model_type).await {
830 Ok(llm_response) => {
831 debug!("LLM response received ({} chars)", llm_response.len());
832
833 match parse_entity_resolution_xml(&llm_response) {
835 Ok(resolution) => {
836 debug!(
837 "LLM resolution parsed: match_type={:?}, confidence={}",
838 resolution.match_type, resolution.confidence
839 );
840
841 if let Some(entity_id) = resolution.entity_id {
843 if let Some(entity) =
845 entities_in_room.iter().find(|e| e.id == entity_id)
846 {
847 (Some(entity.clone()), resolution.confidence)
848 } else {
849 debug!("Entity ID from LLM not found in room");
850 (None, resolution.confidence)
851 }
852 } else if !resolution.matches.is_empty() {
853 let match_name = resolution.matches[0].name.to_lowercase();
855 if let Some(entity) = entities_in_room.iter().find(|e| {
856 e.name
857 .as_ref()
858 .map(|n| n.to_lowercase() == match_name)
859 .unwrap_or(false)
860 || e.username
861 .as_ref()
862 .map(|u| u.to_lowercase() == match_name)
863 .unwrap_or(false)
864 }) {
865 (Some(entity.clone()), resolution.confidence)
866 } else {
867 (None, resolution.confidence)
868 }
869 } else {
870 (None, resolution.confidence)
871 }
872 }
873 Err(e) => {
874 warn!("Failed to parse LLM entity resolution: {}", e);
875 (None, 0.0)
876 }
877 }
878 }
879 Err(e) => {
880 warn!("LLM call failed for entity resolution: {}", e);
881 (None, 0.0)
882 }
883 }
884 } else {
885 debug!("LLM resolution disabled, using fallback strategies only");
886 (None, 0.0)
887 };
888
889 if let Some(entity) = resolved_entity {
891 info!(
892 "Entity resolved via LLM (confidence: {}, {}ms)",
893 confidence,
894 start_time.elapsed().as_millis()
895 );
896
897 cache_entity(cache_key, Some(entity.clone()), confidence, config);
899
900 return Ok(Some(entity));
901 }
902
903 debug!("Using fallback resolution strategies");
905
906 if let Some(entity_name) = state.values.get("entityName") {
908 debug!("Trying state-based resolution with hint: {}", entity_name);
909 let query = entity_name.to_lowercase();
910
911 for entity in &entities_in_room {
912 if let Some(name) = &entity.name {
914 if name.to_lowercase().contains(&query) {
915 info!(
916 "Entity resolved via state hint ({}ms)",
917 start_time.elapsed().as_millis()
918 );
919 let result = Some(entity.clone());
920 cache_entity(cache_key, result.clone(), 0.8, config);
921 return Ok(result);
922 }
923 }
924
925 if let Some(username) = &entity.username {
927 if username.to_lowercase().contains(&query) {
928 info!(
929 "Entity resolved via state hint username ({}ms)",
930 start_time.elapsed().as_millis()
931 );
932 let result = Some(entity.clone());
933 cache_entity(cache_key, result.clone(), 0.8, config);
934 return Ok(result);
935 }
936 }
937 }
938 }
939
940 let text = message.content.text.to_lowercase();
942 debug!("Trying mention-based resolution");
943
944 for entity in &entities_in_room {
946 if let Some(username) = &entity.username {
947 let mention = format!("@{}", username.to_lowercase());
948 if text.contains(&mention) {
949 info!(
950 "Entity resolved via @mention ({}ms)",
951 start_time.elapsed().as_millis()
952 );
953 let result = Some(entity.clone());
954 cache_entity(cache_key, result.clone(), 0.9, config);
955 return Ok(result);
956 }
957 }
958 }
959
960 debug!("Trying name-based resolution");
962 for entity in &entities_in_room {
963 if let Some(name) = &entity.name {
964 if name.len() > 2 && text.contains(&name.to_lowercase()) {
966 info!(
967 "Entity resolved via name mention ({}ms)",
968 start_time.elapsed().as_millis()
969 );
970 let result = Some(entity.clone());
971 cache_entity(cache_key, result.clone(), 0.7, config);
972 return Ok(result);
973 }
974 }
975 }
976
977 debug!("Trying pronoun-based resolution");
979 if text.contains("you") || text.contains("your") {
980 for entity in &entities_in_room {
982 if entity.id == agent_id {
983 info!(
984 "Entity resolved via pronoun 'you' -> agent ({}ms)",
985 start_time.elapsed().as_millis()
986 );
987 let result = Some(entity.clone());
988 cache_entity(cache_key, result.clone(), 0.6, config);
989 return Ok(result);
990 }
991 }
992 }
993
994 if text.contains("me") || text.contains("my") || text.contains("i ") {
995 for entity in &entities_in_room {
997 if entity.id == message.entity_id {
998 info!(
999 "Entity resolved via pronoun 'me' -> sender ({}ms)",
1000 start_time.elapsed().as_millis()
1001 );
1002 let result = Some(entity.clone());
1003 cache_entity(cache_key, result.clone(), 0.6, config);
1004 return Ok(result);
1005 }
1006 }
1007 }
1008
1009 if !relationships.is_empty() {
1011 debug!("Trying relationship-based resolution");
1012 let interaction_data = get_recent_interactions(
1013 message.entity_id,
1014 &entities_in_room,
1015 room.id,
1016 &recent_messages,
1017 &relationships,
1018 );
1019
1020 if let Some((entity, _, score)) = interaction_data.first() {
1022 if *score > 0 {
1023 info!(
1024 "Entity resolved via relationships (score: {}, {}ms)",
1025 score,
1026 start_time.elapsed().as_millis()
1027 );
1028 let result = Some(entity.clone());
1029 cache_entity(cache_key, result.clone(), 0.5, config);
1030 return Ok(result);
1031 }
1032 }
1033 }
1034
1035 info!(
1037 "No entity resolved ({}ms)",
1038 start_time.elapsed().as_millis()
1039 );
1040 cache_entity(cache_key, None, 0.0, config);
1041 Ok(None)
1042}
1043
1044pub fn create_unique_uuid_for_entity(agent_id: UUID, base_user_id: &str) -> UUID {
1053 if base_user_id == agent_id.to_string() {
1055 return agent_id;
1056 }
1057
1058 let combined_string = format!("{}:{}", base_user_id, agent_id);
1061
1062 string_to_uuid(&combined_string)
1064}
1065
1066pub fn get_entity_details(
1075 _room: &Room,
1076 entities: &[Entity],
1077) -> Vec<HashMap<String, serde_json::Value>> {
1078 let mut unique_entities: HashMap<UUID, HashMap<String, serde_json::Value>> = HashMap::new();
1079
1080 for entity in entities {
1081 if unique_entities.contains_key(&entity.id) {
1082 continue;
1083 }
1084
1085 let name = entity.name.clone().unwrap_or_else(|| {
1087 entity
1088 .username
1089 .clone()
1090 .unwrap_or_else(|| "Unknown".to_string())
1091 });
1092
1093 let mut entity_detail = HashMap::new();
1094 entity_detail.insert("id".to_string(), serde_json::json!(entity.id.to_string()));
1095 entity_detail.insert("name".to_string(), serde_json::json!(name));
1096
1097 let metadata_json = serde_json::to_value(&entity.metadata).unwrap_or(serde_json::json!({}));
1099 entity_detail.insert("data".to_string(), metadata_json);
1100
1101 unique_entities.insert(entity.id, entity_detail);
1102 }
1103
1104 unique_entities.into_values().collect()
1105}
1106
1107pub fn format_entities(entities: &[Entity]) -> String {
1115 entities
1116 .iter()
1117 .map(|entity| {
1118 let name = entity.name.clone().unwrap_or_else(|| {
1119 entity
1120 .username
1121 .clone()
1122 .unwrap_or_else(|| "Unknown".to_string())
1123 });
1124
1125 let mut header = format!("\"{}\"\nID: {}", name, entity.id);
1126
1127 if !entity.metadata.is_empty() {
1128 if let Ok(metadata_str) = serde_json::to_string(&entity.metadata) {
1129 header.push_str(&format!("\nData: {}\n", metadata_str));
1130 } else {
1131 header.push('\n');
1132 }
1133 } else {
1134 header.push('\n');
1135 }
1136
1137 header
1138 })
1139 .collect::<Vec<_>>()
1140 .join("\n")
1141}
1142
1143pub fn get_recent_interactions(
1155 source_entity_id: UUID,
1156 candidate_entities: &[Entity],
1157 _room_id: UUID,
1158 recent_messages: &[Memory],
1159 relationships: &[Relationship],
1160) -> Vec<(Entity, Vec<Memory>, usize)> {
1161 let mut results: Vec<(Entity, Vec<Memory>, usize)> = Vec::new();
1162
1163 for entity in candidate_entities {
1164 let mut interactions: Vec<Memory> = Vec::new();
1165 let mut interaction_score = 0;
1166
1167 let direct_replies: Vec<Memory> = recent_messages
1169 .iter()
1170 .filter(|msg| {
1171 msg.entity_id == source_entity_id || msg.entity_id == entity.id
1172 })
1174 .cloned()
1175 .collect();
1176
1177 interactions.extend(direct_replies.clone());
1178
1179 let relationship = relationships.iter().find(|rel| {
1181 (rel.entity_id_a == source_entity_id && rel.entity_id_b == entity.id)
1182 || (rel.entity_id_b == source_entity_id && rel.entity_id_a == entity.id)
1183 });
1184
1185 if let Some(rel) = relationship {
1186 if let Some(interactions_count) = rel.metadata.get("interactions") {
1187 if let Some(count) = interactions_count.as_u64() {
1188 interaction_score = count as usize;
1189 }
1190 }
1191 }
1192
1193 interaction_score += direct_replies.len();
1195
1196 let unique_interactions: Vec<Memory> = interactions.into_iter().rev().take(5).collect();
1198
1199 results.push((entity.clone(), unique_interactions, interaction_score));
1200 }
1201
1202 results.sort_by(|a, b| b.2.cmp(&a.2));
1204 results
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use crate::types::Metadata;
1211 use uuid::Uuid;
1212
1213 #[test]
1214 fn test_create_unique_uuid_for_entity() {
1215 let agent_id = Uuid::new_v4();
1216 let user_id = "user123";
1217
1218 let uuid1 = create_unique_uuid_for_entity(agent_id, user_id);
1219 let uuid2 = create_unique_uuid_for_entity(agent_id, user_id);
1220
1221 assert_eq!(uuid1, uuid2);
1223
1224 assert_ne!(uuid1, agent_id);
1226
1227 let agent_id_result = create_unique_uuid_for_entity(agent_id, &agent_id.to_string());
1229 assert_eq!(agent_id_result, agent_id);
1230 }
1231
1232 #[test]
1233 fn test_format_entities() {
1234 let entity = Entity {
1235 id: Uuid::new_v4(),
1236 agent_id: Uuid::new_v4(),
1237 name: Some("Test User".to_string()),
1238 username: Some("testuser".to_string()),
1239 email: None,
1240 avatar_url: None,
1241 metadata: Metadata::new(),
1242 created_at: Some(12345),
1243 };
1244
1245 let formatted = format_entities(&[entity.clone()]);
1246 assert!(formatted.contains("Test User"));
1247 assert!(formatted.contains(&entity.id.to_string()));
1248 }
1249
1250 #[test]
1251 fn test_extract_xml_tag() {
1252 let xml = "<response><entityId>12345</entityId><type>EXACT_MATCH</type></response>";
1253
1254 let entity_id = extract_xml_tag(xml, "entityId");
1255 assert_eq!(entity_id, Some("12345".to_string()));
1256
1257 let match_type = extract_xml_tag(xml, "type");
1258 assert_eq!(match_type, Some("EXACT_MATCH".to_string()));
1259
1260 let missing = extract_xml_tag(xml, "missing");
1261 assert_eq!(missing, None);
1262 }
1263
1264 #[test]
1265 fn test_extract_xml_section() {
1266 let xml = r#"<response>
1267 <matches>
1268 <match><name>John</name></match>
1269 <match><name>Jane</name></match>
1270 </matches>
1271 </response>"#;
1272
1273 let matches_section = extract_xml_section(xml, "matches");
1274 assert!(matches_section.is_some());
1275
1276 let section = matches_section.unwrap();
1277 assert!(section.contains("<match>"));
1278 assert!(section.contains("John"));
1279 assert!(section.contains("Jane"));
1280 }
1281
1282 #[test]
1283 fn test_extract_xml_sections() {
1284 let xml = r#"<matches>
1285 <match><name>John</name><reason>First match</reason></match>
1286 <match><name>Jane</name><reason>Second match</reason></match>
1287 </matches>"#;
1288
1289 let sections = extract_xml_sections(xml, "match");
1290 assert_eq!(sections.len(), 2);
1291 assert!(sections[0].contains("John"));
1292 assert!(sections[1].contains("Jane"));
1293 }
1294
1295 #[test]
1296 fn test_parse_entity_resolution_xml() {
1297 let xml = r#"<response>
1298 <entityId>550e8400-e29b-41d4-a716-446655440000</entityId>
1299 <type>EXACT_MATCH</type>
1300 <matches>
1301 <match>
1302 <name>John Doe</name>
1303 <reason>Exact ID match</reason>
1304 </match>
1305 </matches>
1306 </response>"#;
1307
1308 let result = parse_entity_resolution_xml(xml);
1309 assert!(result.is_ok());
1310
1311 let resolution = result.unwrap();
1312 assert!(resolution.entity_id.is_some());
1313 assert_eq!(resolution.match_type, MatchType::ExactMatch);
1314 assert_eq!(resolution.matches.len(), 1);
1315 assert_eq!(resolution.matches[0].name, "John Doe");
1316 assert_eq!(resolution.matches[0].reason, "Exact ID match");
1317 }
1318
1319 #[test]
1320 fn test_parse_entity_resolution_xml_no_id() {
1321 let xml = r#"<response>
1322 <entityId>null</entityId>
1323 <type>UNKNOWN</type>
1324 <matches></matches>
1325 </response>"#;
1326
1327 let result = parse_entity_resolution_xml(xml);
1328 assert!(result.is_ok());
1329
1330 let resolution = result.unwrap();
1331 assert!(resolution.entity_id.is_none());
1332 assert_eq!(resolution.match_type, MatchType::Unknown);
1333 assert_eq!(resolution.matches.len(), 0);
1334 }
1335
1336 #[test]
1337 fn test_get_entity_details() {
1338 let room = Room {
1339 id: Uuid::new_v4(),
1340 agent_id: Some(Uuid::new_v4()),
1341 name: "Test Room".to_string(),
1342 source: "test".to_string(),
1343 channel_type: crate::types::ChannelType::GuildText,
1344 channel_id: None,
1345 server_id: None,
1346 world_id: Uuid::new_v4(),
1347 metadata: Metadata::new(),
1348 created_at: Some(12345),
1349 };
1350
1351 let entity1 = Entity {
1352 id: Uuid::new_v4(),
1353 agent_id: room.agent_id.unwrap(),
1354 name: Some("Alice".to_string()),
1355 username: Some("alice".to_string()),
1356 email: None,
1357 avatar_url: None,
1358 metadata: Metadata::new(),
1359 created_at: Some(12345),
1360 };
1361
1362 let entity2 = Entity {
1363 id: Uuid::new_v4(),
1364 agent_id: room.agent_id.unwrap(),
1365 name: Some("Bob".to_string()),
1366 username: Some("bob".to_string()),
1367 email: None,
1368 avatar_url: None,
1369 metadata: Metadata::new(),
1370 created_at: Some(12345),
1371 };
1372
1373 let details = get_entity_details(&room, &[entity1.clone(), entity2.clone()]);
1374 assert_eq!(details.len(), 2);
1375
1376 let names: Vec<String> = details
1378 .iter()
1379 .filter_map(|d| {
1380 d.get("name")
1381 .and_then(|v| v.as_str())
1382 .map(|s| s.to_string())
1383 })
1384 .collect();
1385 assert!(names.contains(&"Alice".to_string()));
1386 assert!(names.contains(&"Bob".to_string()));
1387 }
1388
1389 #[test]
1390 fn test_get_recent_interactions() {
1391 let source_entity_id = Uuid::new_v4();
1392 let target_entity_id = Uuid::new_v4();
1393 let room_id = Uuid::new_v4();
1394 let agent_id = Uuid::new_v4();
1395
1396 let _source_entity = Entity {
1397 id: source_entity_id,
1398 agent_id,
1399 name: Some("Source".to_string()),
1400 username: None,
1401 email: None,
1402 avatar_url: None,
1403 metadata: Metadata::new(),
1404 created_at: Some(12345),
1405 };
1406
1407 let target_entity = Entity {
1408 id: target_entity_id,
1409 agent_id,
1410 name: Some("Target".to_string()),
1411 username: None,
1412 email: None,
1413 avatar_url: None,
1414 metadata: Metadata::new(),
1415 created_at: Some(12345),
1416 };
1417
1418 let messages = vec![];
1419 let relationships = vec![];
1420
1421 let interactions = get_recent_interactions(
1422 source_entity_id,
1423 &[target_entity.clone()],
1424 room_id,
1425 &messages,
1426 &relationships,
1427 );
1428
1429 assert_eq!(interactions.len(), 1);
1430 assert_eq!(interactions[0].0.id, target_entity_id);
1431 assert_eq!(interactions[0].2, 0); }
1433
1434 #[test]
1435 fn test_get_recent_interactions_with_relationship() {
1436 let source_entity_id = Uuid::new_v4();
1437 let target_entity_id = Uuid::new_v4();
1438 let room_id = Uuid::new_v4();
1439 let agent_id = Uuid::new_v4();
1440
1441 let target_entity = Entity {
1442 id: target_entity_id,
1443 agent_id,
1444 name: Some("Target".to_string()),
1445 username: None,
1446 email: None,
1447 avatar_url: None,
1448 metadata: Metadata::new(),
1449 created_at: Some(12345),
1450 };
1451
1452 let mut metadata = Metadata::new();
1454 metadata.insert("interactions".to_string(), serde_json::json!(5));
1455
1456 let relationship = Relationship {
1457 entity_id_a: source_entity_id,
1458 entity_id_b: target_entity_id,
1459 relationship_type: "friend".to_string(),
1460 agent_id,
1461 metadata,
1462 created_at: Some(12345),
1463 };
1464
1465 let messages = vec![];
1466 let relationships = vec![relationship];
1467
1468 let interactions = get_recent_interactions(
1469 source_entity_id,
1470 &[target_entity.clone()],
1471 room_id,
1472 &messages,
1473 &relationships,
1474 );
1475
1476 assert_eq!(interactions.len(), 1);
1477 assert_eq!(interactions[0].0.id, target_entity_id);
1478 assert_eq!(interactions[0].2, 5); }
1480
1481 #[test]
1482 fn test_entity_resolution_config() {
1483 let config = EntityResolutionConfig::default();
1484 assert!(config.use_llm);
1485 assert_eq!(config.cache_ttl, 300);
1486 assert_eq!(config.max_entities, 50);
1487 assert_eq!(config.min_confidence, 0.5);
1488
1489 let custom_config = EntityResolutionConfig {
1490 use_llm: false,
1491 cache_ttl: 600,
1492 max_entities: 100,
1493 context_message_count: 50,
1494 min_confidence: 0.7,
1495 ..Default::default()
1496 };
1497
1498 assert!(!custom_config.use_llm);
1499 assert_eq!(custom_config.cache_ttl, 600);
1500 }
1501
1502 #[test]
1503 fn test_generate_cache_key() {
1504 let message = Memory {
1505 id: Uuid::new_v4(),
1506 entity_id: Uuid::new_v4(),
1507 agent_id: Uuid::new_v4(),
1508 room_id: Uuid::new_v4(),
1509 content: crate::types::Content {
1510 text: "Hello world".to_string(),
1511 ..Default::default()
1512 },
1513 embedding: None,
1514 metadata: None,
1515 created_at: 12345,
1516 unique: None,
1517 similarity: None,
1518 };
1519
1520 let state = State::new();
1521 let key1 = generate_cache_key(&message, &state);
1522 let key2 = generate_cache_key(&message, &state);
1523
1524 assert_eq!(key1, key2);
1526
1527 let mut message2 = message.clone();
1529 message2.entity_id = Uuid::new_v4();
1530 let key3 = generate_cache_key(&message2, &state);
1531 assert_ne!(key1, key3);
1532 }
1533
1534 #[test]
1535 fn test_match_type() {
1536 assert_eq!(MatchType::ExactMatch, MatchType::ExactMatch);
1537 assert_ne!(MatchType::ExactMatch, MatchType::Unknown);
1538 }
1539
1540 #[test]
1541 fn test_entity_resolution_parsing() {
1542 let xml = r#"<response>
1543 <entityId>550e8400-e29b-41d4-a716-446655440000</entityId>
1544 <type>EXACT_MATCH</type>
1545 <confidence>0.95</confidence>
1546 <matches>
1547 <match>
1548 <name>John Doe</name>
1549 <reason>Exact ID match</reason>
1550 <entityId>550e8400-e29b-41d4-a716-446655440000</entityId>
1551 </match>
1552 </matches>
1553 </response>"#;
1554
1555 let result = parse_entity_resolution_xml(xml);
1556 assert!(result.is_ok());
1557
1558 let resolution = result.unwrap();
1559 assert!(resolution.entity_id.is_some());
1560 assert_eq!(resolution.match_type, MatchType::ExactMatch);
1561 assert_eq!(resolution.confidence, 0.95);
1562 assert_eq!(resolution.matches.len(), 1);
1563 assert_eq!(resolution.matches[0].name, "John Doe");
1564 }
1565}