zoey_core/
entities.rs

1//! Entity resolution and management utilities
2//!
3//! This module provides utilities for resolving entity names within conversations
4//! using LLM-based context analysis and fallback matching strategies.
5//!
6//! # Key Features
7//!
8//! - **LLM-based resolution**: Uses language models to resolve ambiguous entity references
9//! - **Context-aware**: Considers recent messages, relationships, and room participants
10//! - **Permission filtering**: Respects world roles (Owner/Admin/Moderator/Member)
11//! - **Multiple strategies**: Falls back to direct matching when LLM isn't available
12//! - **Interaction tracking**: Prioritizes entities based on relationship strength
13//!
14//! # Usage
15//!
16//! ```rust,no_run
17//! use zoey_core::{find_entity_by_name, RuntimeRef, Memory, State};
18//! use std::sync::Arc;
19//!
20//! async fn resolve_entity_example(
21//!     runtime: Arc<RuntimeRef>,
22//!     message: &Memory,
23//!     state: &State,
24//! ) -> zoey_core::Result<()> {
25//!     // Convert RuntimeRef to type-erased Arc for the function
26//!     let runtime_any = runtime.as_any_arc();
27//!     
28//!     // Find entity by name from message context
29//!     if let Some(entity) = find_entity_by_name(runtime_any, message, state).await? {
30//!         println!("Resolved entity: {:?}", entity.name);
31//!     } else {
32//!         println!("No entity found");
33//!     }
34//!     
35//!     Ok(())
36//! }
37//! ```
38//!
39//! # Entity Resolution Strategies
40//!
41//! 1. **State-based**: Checks `state.values["entityName"]` for explicit hints
42//! 2. **Mention matching**: Looks for @username or direct name mentions
43//! 3. **Pronoun resolution**: Resolves "you" (agent) and "me" (sender)
44//! 4. **Relationship-based**: Uses interaction history for ambiguous cases
45//!
46//! # Thread Safety
47//!
48//! All functions in this module are designed to work with `Arc<RuntimeRef>` which
49//! provides thread-safe access to the runtime without blocking other operations.
50
51use 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/// Entity resolution result from LLM
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub(crate) struct EntityResolution {
71    /// Entity ID if found
72    entity_id: Option<UUID>,
73
74    /// Match type
75    #[serde(rename = "type")]
76    match_type: MatchType,
77
78    /// Matching entities with reasons
79    matches: Vec<EntityMatch>,
80
81    /// Confidence score (0.0 - 1.0)
82    #[serde(default)]
83    confidence: f32,
84}
85
86/// Entity match information
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub(crate) struct EntityMatch {
90    /// Matched name
91    name: String,
92
93    /// Reason for match
94    reason: String,
95
96    /// Entity ID if known
97    #[serde(skip_serializing_if = "Option::is_none")]
98    entity_id: Option<UUID>,
99}
100
101/// Match type for entity resolution
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
104pub(crate) enum MatchType {
105    /// Exact ID match
106    ExactMatch,
107    /// Username/handle match
108    UsernameMatch,
109    /// Display name match
110    NameMatch,
111    /// Relationship-based match
112    RelationshipMatch,
113    /// Ambiguous - multiple possible matches
114    Ambiguous,
115    /// Unknown - no match found
116    Unknown,
117}
118
119/// Entity resolution configuration
120#[derive(Debug, Clone)]
121pub struct EntityResolutionConfig {
122    /// Use LLM for resolution (default: true)
123    pub use_llm: bool,
124
125    /// Model type to use for resolution
126    pub model_type: ModelType,
127
128    /// Cache TTL in seconds (default: 300)
129    pub cache_ttl: u64,
130
131    /// Maximum entities to consider (default: 50)
132    pub max_entities: usize,
133
134    /// Recent message count for context (default: 20)
135    pub context_message_count: usize,
136
137    /// Minimum confidence threshold (default: 0.5)
138    pub min_confidence: f32,
139
140    /// Enable metrics collection
141    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/// Entity resolution cache entry
159#[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
166/// Entity resolution cache (thread-safe)
167type EntityCache = Arc<RwLock<HashMap<String, CacheEntry>>>;
168
169/// Global entity resolution cache
170static ENTITY_CACHE: once_cell::sync::Lazy<EntityCache> =
171    once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(HashMap::new())));
172
173/// Entity resolution template for resolving entity names based on context
174const 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/// Parse XML response from entity resolution
211#[instrument(skip(xml), level = "debug")]
212pub(crate) fn parse_entity_resolution_xml(xml: &str) -> Result<EntityResolution> {
213    debug!("Parsing entity resolution XML");
214
215    // Extract entity ID
216    let entity_id = extract_xml_tag(xml, "entityId");
217
218    // Extract match type
219    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    // Extract confidence if present
230    let confidence = extract_xml_tag(xml, "confidence")
231        .and_then(|c| c.parse::<f32>().ok())
232        .unwrap_or(0.5);
233
234    // Parse matches
235    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    // Parse entity ID
256    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
285/// Extract content of an XML tag
286pub(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
303/// Extract content of an XML section (including nested tags)
304pub(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
317/// Extract multiple XML sections with the same tag
318pub(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
339/// Generate cache key for entity resolution
340pub(crate) fn generate_cache_key(message: &Memory, state: &State) -> String {
341    // Include message context and state hints for cache key
342    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
356/// Check and clean expired cache entries
357pub(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
365/// Get entity from cache if available
366pub(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        // Check if entry is still valid
377        if entry.timestamp.elapsed().as_secs() < config.cache_ttl {
378            // Check confidence threshold
379            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
396/// Store entity in cache
397pub(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
420/// Call LLM for entity resolution using registered model providers
421async fn call_llm_for_entity_resolution(
422    agent_runtime: &AgentRuntime,
423    prompt: &str,
424    model_type: ModelType,
425) -> Result<String> {
426    // Get model handlers for the specified model type
427    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", // Default to small for entity resolution
434    };
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        // Get the highest priority provider
448        let provider = &handlers[0];
449        info!(
450            "Using LLM provider for entity resolution: {} (priority: {})",
451            provider.name, provider.priority
452        );
453
454        // Get model settings
455        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); // Lower temperature for structured output
476
477            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); // Enough for entity resolution
481
482            (model, temp, tokens)
483        };
484
485        // Create parameters for the model
486        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        // Call the model handler
508        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
530/// Finds an entity by name with default configuration
531///
532/// This is a convenience wrapper around `find_entity_by_name_with_config`
533/// using default settings.
534pub 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/// Finds an entity by name in the given runtime environment with custom configuration.
544///
545/// This function uses LLM-based entity resolution to find the most appropriate entity
546/// based on the context of the message and recent interactions.
547///
548/// # Arguments
549/// * `runtime` - The agent runtime environment (type-erased, should be RuntimeRef)
550/// * `message` - The memory message containing relevant information
551/// * `state` - The current state of the system
552/// * `config` - Configuration options for entity resolution
553///
554/// # Returns
555/// A result containing the found entity or None if not found
556///
557/// # Production Features
558/// - LLM-based resolution with fallback strategies
559/// - Caching with configurable TTL
560/// - Metrics and tracing
561/// - Confidence thresholds
562/// - Permission filtering based on world roles
563///
564/// # Note
565/// This function expects `runtime` to be `Arc<RuntimeRef>` (use `RuntimeRef::new()` to wrap)
566#[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 expired cache entries periodically
581    clean_cache(&ENTITY_CACHE, config.cache_ttl);
582
583    // Check cache first
584    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    // Try to get RuntimeRef first (preferred method)
596    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        // Fallback: try direct Arc<RwLock<AgentRuntime>> downcast
603        // This is a workaround - in production, always use RuntimeRef
604        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    // Lock runtime for reading
612    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    // Get the database adapter
618    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    // Store agent info for later use
629    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    // 1. Get the room
635    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        // Fallback: fetch from database
654        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    // 2. Get the world
664    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    // 3. Get all entities in the room with components
680    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            // Limit entities if configured
684            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    // 4. Filter components based on permissions
702    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        // Component filtering based on roles would happen here
729        // Note: The current Entity structure doesn't include components field
730        // In a full implementation with Entity.components, we would filter based on:
731        // 1. component.source_entity_id == message.entity_id (requester's own components)
732        // 2. _world_roles[component.source_entity_id] in [Owner, Admin] (admin visibility)
733        // 3. component.source_entity_id == agent_id (agent's components)
734
735        debug!(
736            "Loaded {} entities with permission filtering",
737            entities_in_room.len()
738        );
739    }
740
741    // 5. Get relationships for the message sender
742    // Note: This requires a get_relationships method on the adapter
743    // The IDatabaseAdapter trait doesn't currently expose this
744    // In production, extend the trait with:
745    // async fn get_relationships(&self, entity_id: UUID) -> Result<Vec<Relationship>>;
746    let relationships: Vec<Relationship> = vec![]; // Placeholder
747
748    // 6. Compose prompt for LLM
749    let engine = TemplateEngine::new();
750
751    let mut template_data: HashMap<String, serde_json::Value> = HashMap::new();
752
753    // Find sender entity
754    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    // Format entities in room
773    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    // Get recent messages
780    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    // Format recent messages with entity names
792    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    // Render the prompt template
818    let prompt = engine.render(ENTITY_RESOLUTION_TEMPLATE, &template_data)?;
819    debug!(
820        "Generated entity resolution prompt ({} chars)",
821        prompt.len()
822    );
823
824    // 7. Use LLM to resolve the entity (if enabled)
825    let (resolved_entity, confidence): (Option<Entity>, f32) = if config.use_llm {
826        debug!("Attempting LLM-based entity resolution");
827
828        // Call the LLM model using registered providers
829        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                // Parse the LLM response
834                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 we got an exact entity ID match
842                        if let Some(entity_id) = resolution.entity_id {
843                            // Find the entity in our list
844                            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                            // Try to match by name from the matches
854                            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 LLM provided a result, use it
890    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 the result
898        cache_entity(cache_key, Some(entity.clone()), confidence, config);
899
900        return Ok(Some(entity));
901    }
902
903    // Fallback strategies
904    debug!("Using fallback resolution strategies");
905
906    // Strategy 1: Check state for explicit entity name
907    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            // Check name
913            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            // Check username
926            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    // Strategy 2: Check content.text for mentions (@username, name)
941    let text = message.content.text.to_lowercase();
942    debug!("Trying mention-based resolution");
943
944    // Look for @mentions
945    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    // Look for direct name mentions
961    debug!("Trying name-based resolution");
962    for entity in &entities_in_room {
963        if let Some(name) = &entity.name {
964            // Only match if name is significant (>2 chars) to avoid false positives
965            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    // Strategy 3: Use interaction history to resolve ambiguous references
978    debug!("Trying pronoun-based resolution");
979    if text.contains("you") || text.contains("your") {
980        // "you" likely refers to the agent
981        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        // "me" refers to the message sender
996        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    // Strategy 4: Use relationship strength if available
1010    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        // Return entity with highest interaction score (if any significant interactions)
1021        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    // No match found
1036    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
1044/// Function to create a unique UUID based on the runtime and base user ID.
1045///
1046/// # Arguments
1047/// * `agent_id` - The agent ID
1048/// * `base_user_id` - The base user ID to use in generating the UUID
1049///
1050/// # Returns
1051/// The unique UUID generated based on the agent and base user ID
1052pub fn create_unique_uuid_for_entity(agent_id: UUID, base_user_id: &str) -> UUID {
1053    // If the base user ID is the agent ID, return it directly
1054    if base_user_id == agent_id.to_string() {
1055        return agent_id;
1056    }
1057
1058    // Use a deterministic approach to generate a new UUID based on both IDs
1059    // This creates a unique ID for each user+agent combination while still being deterministic
1060    let combined_string = format!("{}:{}", base_user_id, agent_id);
1061
1062    // Create a namespace UUID (version 5) from the combined string
1063    string_to_uuid(&combined_string)
1064}
1065
1066/// Get details for entities in a room
1067///
1068/// # Arguments
1069/// * `_room` - The room object (for future use)
1070/// * `entities` - The list of entities in the room
1071///
1072/// # Returns
1073/// A vector of entity detail maps
1074pub 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        // Get primary name (prefer source-specific name if available)
1086        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        // Include metadata
1098        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
1107/// Format entities into a string representation
1108///
1109/// # Arguments
1110/// * `entities` - The list of entities to format
1111///
1112/// # Returns
1113/// A formatted string representing the entities
1114pub 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
1143/// Get recent interactions between entities
1144///
1145/// # Arguments
1146/// * `source_entity_id` - The source entity ID
1147/// * `candidate_entities` - Candidate entities to check interactions with
1148/// * `_room_id` - The room ID (for future use)
1149/// * `recent_messages` - Recent messages in the room
1150/// * `relationships` - Relationships between entities
1151///
1152/// # Returns
1153/// A vector of interaction data sorted by strength
1154pub 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        // Get direct replies using inReplyTo (if available in content metadata)
1168        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                // Add logic to check inReplyTo from content metadata if needed
1173            })
1174            .cloned()
1175            .collect();
1176
1177        interactions.extend(direct_replies.clone());
1178
1179        // Get relationship strength from metadata
1180        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        // Add bonus points for recent direct replies
1194        interaction_score += direct_replies.len();
1195
1196        // Keep last few messages for context
1197        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    // Sort by interaction score descending
1203    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        // Should be deterministic
1222        assert_eq!(uuid1, uuid2);
1223
1224        // Should be different from agent_id
1225        assert_ne!(uuid1, agent_id);
1226
1227        // Agent ID should return itself
1228        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        // Check that both entities are included
1377        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); // No interactions
1432    }
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        // Create a relationship with interaction metadata
1453        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); // 5 interactions from relationship metadata
1479    }
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        // Same message and state should produce same key
1525        assert_eq!(key1, key2);
1526
1527        // Different entity_id should produce different key
1528        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}