1227 lines
40 KiB
Rust
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)
|
|
})
|
|
}
|