Files
MetaCrate/crates/metacrate-grid-agent/src/perception.rs
Chili Palmer 3a0ead7eb5
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m48s
CI / required (push) Failing after 2m43s
feat(grid-agent): add bounded world perception tools (#124)
2026-08-17 23:52:33 +00:00

1227 lines
40 KiB
Rust

//! 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<WorldSnapshot, PerceptionError>>;
#[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<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LandmarkMetadata {
pub id: UUID,
pub name: String,
pub region_id: Option<UUID>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AgentSnapshot {
pub id: UUID,
pub position: Option<WorldPosition>,
pub health: Option<f32>,
pub sitting_on_local_id: Option<u32>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ParcelSnapshot {
pub name: String,
pub description: String,
pub area: Option<i32>,
pub category: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct EnvironmentSnapshot {
pub water_height: Option<f32>,
pub terrain_height: Option<f32>,
pub sun_phase: Option<f32>,
pub region_access: Option<String>,
}
/// 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<ObservedAvatar>,
pub avatars_truncated: bool,
pub objects: Vec<ObservedObject>,
pub objects_truncated: bool,
pub parcel: Option<ParcelSnapshot>,
pub environment: Option<EnvironmentSnapshot>,
pub inventory: Vec<InventoryMetadata>,
pub inventory_truncated: bool,
pub landmarks: Vec<LandmarkMetadata>,
}
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<u64>,
region_id: Option<UUID>,
}
#[derive(Clone)]
struct CachedSnapshot {
captured: Instant,
snapshot: Arc<WorldSnapshot>,
}
#[derive(Default)]
struct PerceptionState {
lifecycle: Option<Lifecycle>,
cache: Option<CachedSnapshot>,
}
#[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<MAX_IDENTIFIER_BYTES>,
pub tool: BoundedText<MAX_IDENTIFIER_BYTES>,
pub outcome: PerceptionOutcome,
pub duration_millis: u64,
pub result_bytes: usize,
pub cache_hit: bool,
}
#[derive(Clone)]
pub struct PerceptionIngress {
state: Arc<Mutex<PerceptionState>>,
}
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<dyn WorldSnapshotSource>,
conversations: Arc<ConversationStore>,
state: Arc<Mutex<PerceptionState>>,
observations: mpsc::Sender<PerceptionObservation>,
observation_rx: Mutex<Option<mpsc::Receiver<PerceptionObservation>>>,
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<dyn WorldSnapshotSource>,
conversations: Arc<ConversationStore>,
observation_capacity: usize,
) -> Result<Self, PerceptionError> {
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<mpsc::Receiver<PerceptionObservation>> {
lock(&self.observation_rx).take()
}
async fn snapshot(
&self,
cancellation: CancellationToken,
) -> Result<(Arc<WorldSnapshot>, 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<ToolCallOutcome, BackendError>> {
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::<MAX_BODY_BYTES>::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::<MAX_OBSERVABLE_DETAIL_BYTES>::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<Vec<PolicyTool>, 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<String, Value> {
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<String, Value>,
) -> Result<Value, PerceptionError> {
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<String, Value>,
) -> Result<Value, PerceptionError> {
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<String, Value>,
) -> Result<Value, PerceptionError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String, Value>,
) -> Result<Value, PerceptionError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String, Value>,
) -> Result<Value, PerceptionError> {
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<String, Value>,
) -> Result<Value, PerceptionError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String, Value>,
) -> Result<Value, PerceptionError> {
let (page, page_size) = pagination(arguments)?;
let mut items = snapshot.landmarks.iter().collect::<Vec<_>>();
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::<Vec<_>>();
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<String, Value>,
conversations: &ConversationStore,
) -> Result<Value, PerceptionError> {
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::<Vec<_>>();
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<Value>,
source_truncated: bool,
}
fn paged_result(
snapshot: &WorldSnapshot,
cache_hit: bool,
tool: &str,
page: PageResult,
) -> Result<Value, PerceptionError> {
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<String, Value>) -> 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<String, Value>) -> Result<f64, PerceptionError> {
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<String, Value>,
name: &str,
default: usize,
) -> Result<usize, PerceptionError> {
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<T>(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<String, Value>) -> Result<(), PerceptionError> {
if arguments.is_empty() {
Ok(())
} else {
Err(PerceptionError::InvalidArguments)
}
}
fn insert_optional(result: &mut Map<String, Value>, name: &str, value: Option<Value>) {
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 {
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::<Vec<_>>()
.join(" ")
}
fn lock<T>(mutex: &Mutex<T>) -> 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)
})
}