//! Bounded, generation-scoped projections of already-observed grid state. #![allow(clippy::missing_errors_doc)] use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture}; use crate::conversation::{ConversationChannel, ConversationStore}; use crate::llm::{ToolDefinition, ToolSchema}; use crate::policy::{ AllowedOrigins, ApprovalRule, AuthorizedAction, Capability, FixedCost, Idempotency, OriginClass, PolicyError, PolicyTool, ResourceCost, Risk, }; use crate::types::{ BoundedText, MAX_BODY_BYTES, MAX_IDENTIFIER_BYTES, MAX_OBSERVABLE_DETAIL_BYTES, ToolCallOutcome, }; use libremetaverse_types::UUID; use libremetaverse_types::compat::CancellationToken; use serde::Serialize; use serde_json::{Map, Value, json}; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fmt; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; pub const LOCATION_TOOL: &str = "world_location"; pub const AGENT_STATE_TOOL: &str = "agent_state"; pub const NEARBY_AVATARS_TOOL: &str = "nearby_avatars"; pub const VISIBLE_OBJECTS_TOOL: &str = "visible_objects"; pub const PARCEL_ENVIRONMENT_TOOL: &str = "parcel_environment"; pub const INVENTORY_SEARCH_TOOL: &str = "inventory_search"; pub const RECEIVED_LANDMARKS_TOOL: &str = "received_landmarks"; pub const RECENT_PARTICIPANTS_TOOL: &str = "recent_participants"; const SNAPSHOT_TTL: Duration = Duration::from_secs(2); const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(3); const MAX_RESULT_BYTES: usize = 32 * 1024; const MAX_SOURCE_ITEMS: usize = 2_048; const MAX_PAGE_SIZE: usize = 20; const MAX_PAGE: usize = 1_000; const MAX_RADIUS_METERS: f64 = 256.0; const MAX_QUERY_BYTES: usize = 128; const MAX_UNTRUSTED_BYTES: usize = 256; pub type SnapshotFuture<'a> = BackendFuture<'a, Result>; #[derive(Clone, Debug, Eq, PartialEq)] pub enum PerceptionError { NotReady, Cancelled, TimedOut, StaleGeneration, InvalidSnapshot, SourceUnavailable, InvalidArguments, ResultTooLarge, } impl fmt::Display for PerceptionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::NotReady => "world perception is unavailable before session readiness", Self::Cancelled => "world perception was cancelled", Self::TimedOut => "world perception timed out", Self::StaleGeneration => "world snapshot belongs to a stale session generation", Self::InvalidSnapshot => "world snapshot failed boundary validation", Self::SourceUnavailable => "world snapshot source is unavailable", Self::InvalidArguments => "perception tool arguments are invalid", Self::ResultTooLarge => "perception result exceeded its byte budget", }) } } impl Error for PerceptionError {} /// An injected read-only view over caches owned by the native manager graph. pub trait WorldSnapshotSource: Send + Sync + 'static { fn capture(&self, generation: u64, cancellation: CancellationToken) -> SnapshotFuture<'_>; } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)] pub struct WorldPosition { pub x: f64, pub y: f64, pub z: f64, } impl WorldPosition { fn valid(self) -> bool { self.x.is_finite() && self.y.is_finite() && self.z.is_finite() && self.x.abs() <= 1_000_000.0 && self.y.abs() <= 1_000_000.0 && self.z.abs() <= 1_000_000.0 } fn distance(self, other: Self) -> f64 { ((self.x - other.x).powi(2) + (self.y - other.y).powi(2) + (self.z - other.z).powi(2)) .sqrt() } } #[derive(Clone, Debug, PartialEq)] pub struct ObservedAvatar { pub id: UUID, pub name: String, pub position: WorldPosition, } #[derive(Clone, Debug, PartialEq)] pub struct ObservedObject { pub id: UUID, pub local_id: u32, pub name: String, pub description: String, pub hover_text: String, pub position: WorldPosition, pub scale: WorldPosition, pub is_attachment: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct InventoryMetadata { pub id: UUID, pub name: String, pub kind: String, pub path: String, pub modified_unix_millis: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct LandmarkMetadata { pub id: UUID, pub name: String, pub region_id: Option, } #[derive(Clone, Debug, Default, PartialEq)] pub struct AgentSnapshot { pub id: UUID, pub position: Option, pub health: Option, pub sitting_on_local_id: Option, } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ParcelSnapshot { pub name: String, pub description: String, pub area: Option, pub category: Option, } #[derive(Clone, Debug, Default, PartialEq)] pub struct EnvironmentSnapshot { pub water_height: Option, pub terrain_height: Option, pub sun_phase: Option, pub region_access: Option, } /// A bounded source projection. It deliberately has no owner identities, /// capability URLs, asset bodies, media URLs, permissions, or chat content. #[derive(Clone, Debug, Default, PartialEq)] pub struct WorldSnapshot { pub generation: u64, pub observed_unix_millis: u64, pub region_id: UUID, pub region_name: String, pub agent: AgentSnapshot, pub avatars: Vec, pub avatars_truncated: bool, pub objects: Vec, pub objects_truncated: bool, pub parcel: Option, pub environment: Option, pub inventory: Vec, pub inventory_truncated: bool, pub landmarks: Vec, } impl WorldSnapshot { fn validate(&mut self, generation: u64) -> Result<(), PerceptionError> { sanitize_snapshot(self); if self.generation != generation { return Err(PerceptionError::StaleGeneration); } if self.region_id == UUID::zero() || self.agent.id == UUID::zero() || self.observed_unix_millis == 0 || self.avatars.len() > MAX_SOURCE_ITEMS || self.objects.len() > MAX_SOURCE_ITEMS || self.inventory.len() > MAX_SOURCE_ITEMS || self.landmarks.len() > MAX_SOURCE_ITEMS || self .agent .position .is_some_and(|position| !position.valid()) || self .agent .health .is_some_and(|health| !health.is_finite() || !(0.0..=100.0).contains(&health)) || self .avatars .iter() .any(|item| item.id == UUID::zero() || !item.position.valid()) || self.objects.iter().any(|item| { item.id == UUID::zero() || !item.position.valid() || !item.scale.valid() }) || self.environment.as_ref().is_some_and(|environment| { environment .water_height .is_some_and(|value| !value.is_finite()) || environment .terrain_height .is_some_and(|value| !value.is_finite()) || environment .sun_phase .is_some_and(|value| !value.is_finite()) }) { return Err(PerceptionError::InvalidSnapshot); } Ok(()) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Lifecycle { generation: Option, region_id: Option, } #[derive(Clone)] struct CachedSnapshot { captured: Instant, snapshot: Arc, } #[derive(Default)] struct PerceptionState { lifecycle: Option, cache: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum PerceptionOutcome { Completed, Rejected, } /// Content-free timing summary. Tool arguments and results are never retained. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PerceptionObservation { pub authorization_id: u64, pub call_id: BoundedText, pub tool: BoundedText, pub outcome: PerceptionOutcome, pub duration_millis: u64, pub result_bytes: usize, pub cache_hit: bool, } #[derive(Clone)] pub struct PerceptionIngress { state: Arc>, } impl fmt::Debug for PerceptionIngress { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("PerceptionIngress") .finish_non_exhaustive() } } impl PerceptionIngress { pub fn connected(&self, generation: u64, region_id: UUID) -> Result<(), PerceptionError> { if generation == 0 || region_id == UUID::zero() { return Err(PerceptionError::InvalidSnapshot); } let mut state = lock(&self.state); state.lifecycle = Some(Lifecycle { generation: Some(generation), region_id: Some(region_id), }); state.cache = None; Ok(()) } pub fn region_changed(&self, generation: u64, region_id: UUID) -> Result<(), PerceptionError> { let mut state = lock(&self.state); let Some(lifecycle) = state.lifecycle else { return Err(PerceptionError::NotReady); }; if lifecycle.generation != Some(generation) { return Err(PerceptionError::StaleGeneration); } if region_id == UUID::zero() { return Err(PerceptionError::InvalidSnapshot); } state.lifecycle = Some(Lifecycle { generation: Some(generation), region_id: Some(region_id), }); state.cache = None; Ok(()) } pub fn disconnected(&self) { let mut state = lock(&self.state); state.lifecycle = None; state.cache = None; } } pub struct PerceptionBackend { source: Arc, conversations: Arc, state: Arc>, observations: mpsc::Sender, observation_rx: Mutex>>, capture_lock: tokio::sync::Mutex<()>, cache_ttl: Duration, snapshot_timeout: Duration, } impl fmt::Debug for PerceptionBackend { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("PerceptionBackend") .field("cache_ttl", &self.cache_ttl) .field("snapshot_timeout", &self.snapshot_timeout) .finish_non_exhaustive() } } impl PerceptionBackend { pub fn new( source: Arc, conversations: Arc, observation_capacity: usize, ) -> Result { if observation_capacity == 0 { return Err(PerceptionError::InvalidSnapshot); } let (observations, observation_rx) = mpsc::channel(observation_capacity); Ok(Self { source, conversations, state: Arc::new(Mutex::new(PerceptionState::default())), observations, observation_rx: Mutex::new(Some(observation_rx)), capture_lock: tokio::sync::Mutex::new(()), cache_ttl: SNAPSHOT_TTL, snapshot_timeout: SNAPSHOT_TIMEOUT, }) } #[cfg(test)] pub(crate) fn with_timing(mut self, cache_ttl: Duration, timeout: Duration) -> Self { self.cache_ttl = cache_ttl; self.snapshot_timeout = timeout; self } #[must_use] pub fn ingress(&self) -> PerceptionIngress { PerceptionIngress { state: Arc::clone(&self.state), } } pub fn take_observations(&self) -> Option> { lock(&self.observation_rx).take() } async fn snapshot( &self, cancellation: CancellationToken, ) -> Result<(Arc, bool), PerceptionError> { if cancellation.is_cancellation_requested() { return Err(PerceptionError::Cancelled); } let lifecycle = lock(&self.state) .lifecycle .ok_or(PerceptionError::NotReady)?; let generation = lifecycle.generation.ok_or(PerceptionError::NotReady)?; if let Some(cached) = lock(&self.state).cache.clone() && cached.captured.elapsed() <= self.cache_ttl && cached.snapshot.generation == generation && Some(cached.snapshot.region_id) == lifecycle.region_id { return Ok((cached.snapshot, true)); } let deadline = tokio::time::Instant::now() + self.snapshot_timeout; let _capture_guard = tokio::select! { () = cancellation.cancelled() => return Err(PerceptionError::Cancelled), result = tokio::time::timeout_at(deadline, self.capture_lock.lock()) => { result.map_err(|_| PerceptionError::TimedOut)? }, }; let lifecycle = lock(&self.state) .lifecycle .ok_or(PerceptionError::NotReady)?; if lifecycle.generation != Some(generation) { return Err(PerceptionError::StaleGeneration); } if let Some(cached) = lock(&self.state).cache.clone() && cached.captured.elapsed() <= self.cache_ttl && cached.snapshot.generation == generation && Some(cached.snapshot.region_id) == lifecycle.region_id { return Ok((cached.snapshot, true)); } let capture = self.source.capture(generation, cancellation.clone()); let mut snapshot = tokio::select! { () = cancellation.cancelled() => return Err(PerceptionError::Cancelled), result = tokio::time::timeout_at(deadline, capture) => { result.map_err(|_| PerceptionError::TimedOut)?? } }; snapshot.validate(generation)?; let current = lock(&self.state) .lifecycle .ok_or(PerceptionError::NotReady)?; if current.generation != Some(generation) || current.region_id != Some(snapshot.region_id) { return Err(PerceptionError::StaleGeneration); } let snapshot = Arc::new(snapshot); lock(&self.state).cache = Some(CachedSnapshot { captured: Instant::now(), snapshot: Arc::clone(&snapshot), }); Ok((snapshot, false)) } async fn execute( &self, action: &AuthorizedAction, cancellation: CancellationToken, ) -> Result<(Value, bool), PerceptionError> { let arguments: Value = serde_json::from_str(action.call().arguments_json.as_str()) .map_err(|_| PerceptionError::InvalidArguments)?; let arguments = arguments .as_object() .ok_or(PerceptionError::InvalidArguments)?; let (snapshot, cache_hit) = self.snapshot(cancellation).await?; let value = match action.call().name.as_str() { LOCATION_TOOL => location_result(&snapshot, cache_hit, arguments)?, AGENT_STATE_TOOL => agent_result(&snapshot, cache_hit, arguments)?, NEARBY_AVATARS_TOOL => avatars_result(&snapshot, cache_hit, arguments)?, VISIBLE_OBJECTS_TOOL => objects_result(&snapshot, cache_hit, arguments)?, PARCEL_ENVIRONMENT_TOOL => environment_result(&snapshot, cache_hit, arguments)?, INVENTORY_SEARCH_TOOL => inventory_result(&snapshot, cache_hit, arguments)?, RECEIVED_LANDMARKS_TOOL => landmarks_result(&snapshot, cache_hit, arguments)?, RECENT_PARTICIPANTS_TOOL => { participants_result(&snapshot, cache_hit, arguments, &self.conversations)? } _ => return Err(PerceptionError::InvalidArguments), }; Ok((value, cache_hit)) } } impl AuthorizedToolBackend for PerceptionBackend { fn apply( &self, action: AuthorizedAction, cancellation: CancellationToken, ) -> BackendFuture<'_, Result> { Box::pin(async move { let started = Instant::now(); let authorization_id = action.authorization_id(); let call_id = action.call().call_id.clone(); let tool = action.call().name.clone(); let execution = self.execute(&action, cancellation).await; let (outcome, result_bytes, cache_hit, status) = match execution { Ok((value, cache_hit)) => { let serialized = serde_json::to_string(&value).map_err(|_| BackendError::Operation { operation: "serialize perception result", })?; if serialized.len() > MAX_RESULT_BYTES { return Err(BackendError::Operation { operation: "bounded perception result", }); } let result = BoundedText::::new("perception.result", serialized) .map_err(|_| BackendError::Operation { operation: "bounded perception result", })?; let bytes = result.len(); ( ToolCallOutcome::Completed { call_id: call_id.clone(), result, }, bytes, cache_hit, PerceptionOutcome::Completed, ) } Err(error) => { let reason = BoundedText::::new( "perception.rejection", error.to_string(), ) .map_err(|_| BackendError::Operation { operation: "bounded perception rejection", })?; ( ToolCallOutcome::Rejected { call_id: call_id.clone(), reason, }, 0, false, PerceptionOutcome::Rejected, ) } }; let _ = self.observations.try_send(PerceptionObservation { authorization_id, call_id, tool, outcome: status, duration_millis: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), result_bytes, cache_hit, }); Ok(outcome) }) } } #[allow(clippy::too_many_lines)] // The stable registry table documents all eight schemas together. pub fn perception_policy_tools() -> Result, PolicyError> { let world_origins = || { AllowedOrigins::new([ OriginClass::PublicChat, OriginClass::UnprivilegedIm, OriginClass::AuthorizedIm, OriginClass::LocalOperator, OriginClass::InternalScheduler, ]) }; let private_origins = || { AllowedOrigins::new([ OriginClass::AuthorizedIm, OriginClass::LocalOperator, OriginClass::InternalScheduler, ]) }; let empty = ToolSchema::Object { properties: BTreeMap::new(), required: BTreeSet::new(), additional_properties: false, }; let page = paged_schema(false); let radius_page = paged_schema(true); let inventory = { let mut properties = BTreeMap::new(); properties.insert("query".to_owned(), ToolSchema::String); properties.insert("page".to_owned(), ToolSchema::Integer); properties.insert("page_size".to_owned(), ToolSchema::Integer); ToolSchema::Object { properties, required: BTreeSet::from(["query".to_owned()]), additional_properties: false, } }; let specs = [ ( LOCATION_TOOL, "Read current region and position with freshness metadata.", empty.clone(), false, ), ( AGENT_STATE_TOOL, "Read safe current agent state with freshness metadata.", empty.clone(), false, ), ( NEARBY_AVATARS_TOOL, "List nearby avatars by bounded radius with deterministic pagination.", radius_page.clone(), false, ), ( VISIBLE_OBJECTS_TOOL, "List tracked objects and safe properties with bounded deterministic pagination.", radius_page, false, ), ( PARCEL_ENVIRONMENT_TOOL, "Read cached parcel and environment facts without requesting network state.", empty, false, ), ( INVENTORY_SEARCH_TOOL, "Search cached inventory metadata; never returns assets, permissions, or URLs.", inventory, true, ), ( RECEIVED_LANDMARKS_TOOL, "List cached landmark metadata with deterministic pagination.", page.clone(), true, ), ( RECENT_PARTICIPANTS_TOOL, "List recent participant UUIDs and channels without conversation content.", page, true, ), ]; specs .into_iter() .map(|(name, description, schema, private)| { PolicyTool::new( ToolDefinition { name: BoundedText::new("perception.tool.name", name)?, description: BoundedText::new("perception.tool.description", description)?, schema, mutating: false, }, Capability::Informational, Risk::ReadOnly, if private { private_origins()? } else { world_origins()? }, ResourceCost::one_call(), Idempotency::Idempotent, ApprovalRule::Never, true, Arc::new(FixedCost(ResourceCost::one_call())), ) }) .collect() } fn paged_schema(radius: bool) -> ToolSchema { let mut properties = BTreeMap::new(); properties.insert("page".to_owned(), ToolSchema::Integer); properties.insert("page_size".to_owned(), ToolSchema::Integer); if radius { properties.insert("radius_meters".to_owned(), ToolSchema::Number); } ToolSchema::Object { properties, required: BTreeSet::new(), additional_properties: false, } } fn base(snapshot: &WorldSnapshot, cache_hit: bool, tool: &str) -> Map { let derived_fields = match tool { NEARBY_AVATARS_TOOL => vec!["distance_meters", "direction"], VISIBLE_OBJECTS_TOOL => vec!["distance_meters"], _ => Vec::new(), }; let mut result = Map::new(); result.insert("schema_version".to_owned(), json!(1)); result.insert("tool".to_owned(), json!(tool)); result.insert("generation".to_owned(), json!(snapshot.generation)); result.insert( "region_id".to_owned(), json!(snapshot.region_id.to_string()), ); result.insert( "observed_unix_millis".to_owned(), json!(snapshot.observed_unix_millis), ); result.insert( "freshness".to_owned(), json!(if cache_hit { "cached" } else { "observed" }), ); result.insert("trust".to_owned(), json!("untrusted_observed_data")); result.insert( "provenance".to_owned(), json!({ "facts": if cache_hit { "cached_observation" } else { "current_observation" }, "derived_fields": derived_fields, "model_inference": "none" }), ); result } fn location_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { require_empty(arguments)?; let mut result = base(snapshot, cache_hit, LOCATION_TOOL); result.insert("region_name".to_owned(), json!(snapshot.region_name)); insert_optional( &mut result, "position", snapshot.agent.position.map(|value| json!(value)), ); Ok(Value::Object(result)) } fn agent_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { require_empty(arguments)?; let mut result = base(snapshot, cache_hit, AGENT_STATE_TOOL); result.insert("agent_id".to_owned(), json!(snapshot.agent.id.to_string())); insert_optional( &mut result, "position", snapshot.agent.position.map(|value| json!(value)), ); insert_optional( &mut result, "health", snapshot.agent.health.map(|value| json!(value)), ); insert_optional( &mut result, "sitting_on_local_id", snapshot.agent.sitting_on_local_id.map(|value| json!(value)), ); Ok(Value::Object(result)) } fn avatars_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { let (page, page_size) = pagination(arguments)?; let radius = radius(arguments)?; let origin = snapshot .agent .position .ok_or(PerceptionError::SourceUnavailable)?; let mut items = snapshot .avatars .iter() .filter(|avatar| avatar.id != snapshot.agent.id) .map(|avatar| (origin.distance(avatar.position), avatar)) .filter(|(distance, _)| *distance <= radius) .collect::>(); items.sort_by(|left, right| { left.0 .total_cmp(&right.0) .then_with(|| left.1.id.to_string().cmp(&right.1.id.to_string())) }); let total = items.len(); let values = page_slice(&items, page, page_size) .iter() .map(|(distance, avatar)| { json!({ "id": avatar.id.to_string(), "name": avatar.name, "position": avatar.position, "distance_meters": round(*distance), "direction": direction(origin, avatar.position), "trust": "untrusted_observed_data" }) }) .collect::>(); paged_result( snapshot, cache_hit, NEARBY_AVATARS_TOOL, PageResult { page, page_size, total, items: values, source_truncated: snapshot.avatars_truncated, }, ) } fn objects_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { let (page, page_size) = pagination(arguments)?; let radius = radius(arguments)?; let origin = snapshot .agent .position .ok_or(PerceptionError::SourceUnavailable)?; let mut items = snapshot .objects .iter() .filter(|object| !object.is_attachment) .map(|object| (origin.distance(object.position), object)) .filter(|(distance, _)| *distance <= radius) .collect::>(); items.sort_by(|left, right| { left.0 .total_cmp(&right.0) .then_with(|| left.1.id.to_string().cmp(&right.1.id.to_string())) }); let total = items.len(); let values = page_slice(&items, page, page_size) .iter() .map(|(distance, object)| { json!({ "id": object.id.to_string(), "local_id": object.local_id, "name": object.name, "description": object.description, "hover_text": object.hover_text, "position": object.position, "scale": object.scale, "distance_meters": round(*distance), "trust": "untrusted_observed_data" }) }) .collect::>(); paged_result( snapshot, cache_hit, VISIBLE_OBJECTS_TOOL, PageResult { page, page_size, total, items: values, source_truncated: snapshot.objects_truncated, }, ) } fn environment_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { require_empty(arguments)?; let mut result = base(snapshot, cache_hit, PARCEL_ENVIRONMENT_TOOL); result.insert( "parcel".to_owned(), snapshot.parcel.as_ref().map_or_else( || unknown("manager_cache_unavailable"), |parcel| { json!({ "status": if cache_hit { "cached" } else { "observed" }, "name": parcel.name, "description": parcel.description, "area": optional_fact(parcel.area.map(|value| json!(value))), "category": optional_fact(parcel.category.as_ref().map(|value| json!(value))), "trust": "untrusted_observed_data" }) }, ), ); result.insert( "environment".to_owned(), snapshot.environment.as_ref().map_or_else( || unknown("manager_cache_unavailable"), |environment| { json!({ "status": if cache_hit { "cached" } else { "observed" }, "water_height": optional_fact(environment.water_height.map(|value| json!(value))), "terrain_height": optional_fact(environment.terrain_height.map(|value| json!(value))), "sun_phase": optional_fact(environment.sun_phase.map(|value| json!(value))), "region_access": optional_fact(environment.region_access.as_ref().map(|value| json!(value))), "trust": "untrusted_observed_data" }) }, ), ); Ok(Value::Object(result)) } fn inventory_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { let query = arguments .get("query") .and_then(Value::as_str) .ok_or(PerceptionError::InvalidArguments)?; if query.is_empty() || query.len() > MAX_QUERY_BYTES { return Err(PerceptionError::InvalidArguments); } let (page, page_size) = pagination(arguments)?; let query = query.to_lowercase(); let mut items = snapshot .inventory .iter() .filter(|item| { item.name.to_lowercase().contains(&query) || item.path.to_lowercase().contains(&query) || item.kind.to_lowercase().contains(&query) }) .collect::>(); items.sort_by(|left, right| { left.path .to_lowercase() .cmp(&right.path.to_lowercase()) .then_with(|| left.id.to_string().cmp(&right.id.to_string())) }); let total = items.len(); let values = page_slice(&items, page, page_size) .iter() .map(|item| { json!({ "id": item.id.to_string(), "name": item.name, "kind": item.kind, "path": item.path, "modified_unix_millis": optional_fact(item.modified_unix_millis.map(|value| json!(value))), "trust": "untrusted_observed_data" }) }) .collect::>(); paged_result( snapshot, cache_hit, INVENTORY_SEARCH_TOOL, PageResult { page, page_size, total, items: values, source_truncated: snapshot.inventory_truncated, }, ) } fn landmarks_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, ) -> Result { let (page, page_size) = pagination(arguments)?; let mut items = snapshot.landmarks.iter().collect::>(); items.sort_by(|left, right| { left.name .to_lowercase() .cmp(&right.name.to_lowercase()) .then_with(|| left.id.to_string().cmp(&right.id.to_string())) }); let total = items.len(); let values = page_slice(&items, page, page_size) .iter() .map(|item| { json!({ "id": item.id.to_string(), "name": item.name, "region_id": optional_fact(item.region_id.map(|id| json!(id.to_string()))), "trust": "untrusted_observed_data" }) }) .collect::>(); paged_result( snapshot, cache_hit, RECEIVED_LANDMARKS_TOOL, PageResult { page, page_size, total, items: values, source_truncated: snapshot.inventory_truncated, }, ) } fn participants_result( snapshot: &WorldSnapshot, cache_hit: bool, arguments: &Map, conversations: &ConversationStore, ) -> Result { let (page, page_size) = pagination(arguments)?; let mut items = conversations.list_metadata(); items.sort_by(|left, right| { right .last_active_unix_millis .cmp(&left.last_active_unix_millis) .then_with(|| left.avatar_id.to_string().cmp(&right.avatar_id.to_string())) .then_with(|| left.channel.cmp(&right.channel)) }); let source_truncated = items.len() > MAX_SOURCE_ITEMS; items.truncate(MAX_SOURCE_ITEMS); let total = items.len(); let values = page_slice(&items, page, page_size) .iter() .map(|item| { json!({ "avatar_id": item.avatar_id.to_string(), "channel": match item.channel { ConversationChannel::PublicChat => "public_chat", ConversationChannel::DirectIm => "direct_im", }, "last_active_unix_millis": item.last_active_unix_millis }) }) .collect::>(); paged_result( snapshot, cache_hit, RECENT_PARTICIPANTS_TOOL, PageResult { page, page_size, total, items: values, source_truncated, }, ) } struct PageResult { page: usize, page_size: usize, total: usize, items: Vec, source_truncated: bool, } fn paged_result( snapshot: &WorldSnapshot, cache_hit: bool, tool: &str, page: PageResult, ) -> Result { let mut result = base(snapshot, cache_hit, tool); result.insert("page".to_owned(), json!(page.page)); result.insert("page_size".to_owned(), json!(page.page_size)); result.insert("total_matches".to_owned(), json!(page.total)); result.insert("source_truncated".to_owned(), json!(page.source_truncated)); result.insert( "has_more".to_owned(), json!(page.page.saturating_add(1).saturating_mul(page.page_size) < page.total), ); result.insert("items".to_owned(), Value::Array(page.items)); let value = Value::Object(result); if serde_json::to_vec(&value).map_or(true, |bytes| bytes.len() > MAX_RESULT_BYTES) { return Err(PerceptionError::ResultTooLarge); } Ok(value) } fn pagination(arguments: &Map) -> Result<(usize, usize), PerceptionError> { let page = optional_usize(arguments, "page", 0)?; let page_size = optional_usize(arguments, "page_size", 20)?; if page > MAX_PAGE || page_size == 0 || page_size > MAX_PAGE_SIZE { return Err(PerceptionError::InvalidArguments); } Ok((page, page_size)) } fn radius(arguments: &Map) -> Result { let radius = arguments.get("radius_meters").map_or(Ok(96.0), |value| { value.as_f64().ok_or(PerceptionError::InvalidArguments) })?; if !radius.is_finite() || !(0.1..=MAX_RADIUS_METERS).contains(&radius) { return Err(PerceptionError::InvalidArguments); } Ok(radius) } fn optional_usize( arguments: &Map, name: &str, default: usize, ) -> Result { arguments.get(name).map_or(Ok(default), |value| { value .as_u64() .and_then(|value| usize::try_from(value).ok()) .ok_or(PerceptionError::InvalidArguments) }) } fn page_slice(items: &[T], page: usize, page_size: usize) -> &[T] { let start = page.saturating_mul(page_size).min(items.len()); let end = start.saturating_add(page_size).min(items.len()); &items[start..end] } fn require_empty(arguments: &Map) -> Result<(), PerceptionError> { if arguments.is_empty() { Ok(()) } else { Err(PerceptionError::InvalidArguments) } } fn insert_optional(result: &mut Map, name: &str, value: Option) { result.insert( name.to_owned(), value.unwrap_or_else(|| unknown("manager_cache_unavailable")), ); } fn unknown(reason: &str) -> Value { json!({"status": "unknown", "reason": reason}) } fn optional_fact(value: Option) -> Value { value.unwrap_or_else(|| unknown("manager_cache_unavailable")) } fn direction(origin: WorldPosition, target: WorldPosition) -> &'static str { let dx = target.x - origin.x; let dy = target.y - origin.y; if dx.abs() < 0.01 && dy.abs() < 0.01 { "here" } else if dx.abs() > dy.abs() { if dx > 0.0 { "east" } else { "west" } } else if dy > 0.0 { "north" } else { "south" } } fn round(value: f64) -> f64 { (value * 100.0).round() / 100.0 } fn sanitize_snapshot(snapshot: &mut WorldSnapshot) { snapshot.region_name = sanitize_untrusted(&snapshot.region_name); for avatar in &mut snapshot.avatars { avatar.name = sanitize_untrusted(&avatar.name); } for object in &mut snapshot.objects { object.name = sanitize_untrusted(&object.name); object.description = sanitize_untrusted(&object.description); object.hover_text = sanitize_untrusted(&object.hover_text); } if let Some(parcel) = &mut snapshot.parcel { parcel.name = sanitize_untrusted(&parcel.name); parcel.description = sanitize_untrusted(&parcel.description); parcel.category = parcel .category .take() .map(|value| sanitize_untrusted(&value)); } if let Some(environment) = &mut snapshot.environment { environment.region_access = environment .region_access .take() .map(|value| sanitize_untrusted(&value)); } for item in &mut snapshot.inventory { item.name = sanitize_untrusted(&item.name); item.kind = sanitize_untrusted(&item.kind); item.path = sanitize_untrusted(&item.path); } for landmark in &mut snapshot.landmarks { landmark.name = sanitize_untrusted(&landmark.name); } } pub(crate) fn sanitize_untrusted(value: &str) -> String { let mut result = String::with_capacity(value.len().min(MAX_UNTRUSTED_BYTES)); for character in value.chars() { let character = if character.is_control() { ' ' } else { character }; if result.len() + character.len_utf8() > MAX_UNTRUSTED_BYTES { break; } result.push(character); } result .split_whitespace() .map(|token| { let normalized = token.to_ascii_lowercase(); if normalized.contains("http://") || normalized.contains("https://") || normalized.contains("secondlife://") || normalized.contains("x-grid-location-info://") { "[url removed]" } else if normalized.starts_with("sk-") || normalized.starts_with("bearer") || normalized.contains("api_key=") || normalized.contains("apikey=") || normalized.contains("password=") || normalized.contains("token=") { "[secret removed]" } else { token } }) .collect::>() .join(" ") } fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } #[must_use] pub fn unix_millis_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) }) }