feat(grid-agent): add bounded world perception tools (#124)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m48s
CI / required (push) Failing after 2m43s

This commit is contained in:
2026-08-17 23:52:33 +00:00
parent 26fd2bf714
commit 3a0ead7eb5
7 changed files with 2269 additions and 37 deletions

View File

@@ -5,6 +5,8 @@ use crate::types::{GridEvent, GridEventKind, ToolCallOutcome};
#[cfg(feature = "live-grid")]
use libremetaverse_types::UUID;
use libremetaverse_types::compat::CancellationToken;
#[cfg(feature = "live-grid")]
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::future::Future;
@@ -96,6 +98,15 @@ pub struct LibremetaverseClientOwner {
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
/// Read-only adapter over caches already owned and populated by the shared
/// native manager graph. Capture never sends a packet or requests a capability.
#[cfg(feature = "live-grid")]
#[derive(Clone)]
pub struct LibremetaverseWorldSnapshotSource {
client: libremetaverse::GridClient,
agent: Arc<libremetaverse::AgentManager>,
}
#[cfg(feature = "live-grid")]
impl fmt::Debug for LibremetaverseClientOwner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -118,6 +129,7 @@ pub struct LibremetaverseSessionBackend {
last_name: String,
password: crate::config::SecretString,
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
@@ -175,7 +187,7 @@ impl LibremetaverseClientOwner {
&self,
connection: crate::config::GridConnection,
) -> Result<LibremetaverseSessionBackend, BackendError> {
self.session_backend_inner(connection, None)
self.session_backend_inner(connection, None, None)
}
/// Creates a supervised live session whose generation owns exactly one
@@ -189,13 +201,28 @@ impl LibremetaverseClientOwner {
connection: crate::config::GridConnection,
ingress: crate::interaction::InteractionIngress,
) -> Result<LibremetaverseSessionBackend, BackendError> {
self.session_backend_inner(connection, Some(ingress))
self.session_backend_inner(connection, Some(ingress), None)
}
/// Creates a session that generation-fences interaction and perception.
///
/// # Errors
///
/// Rejects a malformed avatar identity before any subscription or login.
pub fn session_backend_with_services(
&self,
connection: crate::config::GridConnection,
interaction: crate::interaction::InteractionIngress,
perception: crate::perception::PerceptionIngress,
) -> Result<LibremetaverseSessionBackend, BackendError> {
self.session_backend_inner(connection, Some(interaction), Some(perception))
}
fn session_backend_inner(
&self,
connection: crate::config::GridConnection,
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
) -> Result<LibremetaverseSessionBackend, BackendError> {
let avatar = connection.avatar_name.trim();
let (first_name, last_name) = avatar
@@ -214,6 +241,7 @@ impl LibremetaverseClientOwner {
last_name: last_name.to_owned(),
password: connection.password,
interaction,
perception,
delivery_generation: Arc::clone(&self.delivery_generation),
})
}
@@ -225,6 +253,268 @@ impl LibremetaverseClientOwner {
delivery_generation: Arc::clone(&self.delivery_generation),
}
}
#[must_use]
pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource {
LibremetaverseWorldSnapshotSource {
client: self.client.clone(),
agent: Arc::clone(&self.agent),
}
}
}
#[cfg(feature = "live-grid")]
impl LibremetaverseWorldSnapshotSource {
// One atomic projection keeps native cache reads together. Terrain lookup
// takes integral local coordinates, so finite region positions are floored.
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
fn capture_blocking(
&self,
generation: u64,
) -> Result<crate::perception::WorldSnapshot, crate::perception::PerceptionError> {
let network = self.client.network();
let simulator = network
.native_current_sim()
.ok_or(crate::perception::PerceptionError::SourceUnavailable)?;
if !network.native_connected()
|| !simulator
.native_is_event_queue_running(None)
.unwrap_or(false)
{
return Err(crate::perception::PerceptionError::NotReady);
}
let agent_position = self.agent.sim_position();
let position = crate::perception::WorldPosition {
x: f64::from(agent_position.x),
y: f64::from(agent_position.y),
z: f64::from(agent_position.z),
};
let avatar_cache = simulator
.objects_avatars
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let avatars_truncated = avatar_cache.len() > 2_048;
let mut avatars = BTreeMap::new();
for (local_id, avatar) in avatar_cache
.iter()
.filter(|(_, avatar)| avatar.id != UUID::zero())
{
avatars.insert(
*local_id,
crate::perception::ObservedAvatar {
id: avatar.id,
name: crate::perception::sanitize_untrusted(&avatar.name()),
position: native_position(avatar.position),
},
);
if avatars.len() > 2_048 {
avatars.pop_last();
}
}
drop(avatar_cache);
let avatars = avatars.into_values().collect();
let object_cache = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let objects_truncated = object_cache.len() > 2_048;
let mut objects = BTreeMap::new();
for (local_id, primitive) in object_cache
.iter()
.filter(|(_, primitive)| primitive.id != UUID::zero())
{
let observed = {
let (name, description) = primitive.properties.as_ref().map_or_else(
|| (String::new(), String::new()),
|properties| {
(
crate::perception::sanitize_untrusted(&properties.name),
crate::perception::sanitize_untrusted(&properties.description),
)
},
);
crate::perception::ObservedObject {
id: primitive.id,
local_id: primitive.local_id,
name,
description,
hover_text: crate::perception::sanitize_untrusted(&primitive.text),
position: native_position(primitive.position),
scale: native_position(primitive.scale),
is_attachment: primitive.is_attachment,
}
};
objects.insert(*local_id, observed);
if objects.len() > 2_048 {
objects.pop_last();
}
}
drop(object_cache);
let objects = objects.into_values().collect();
let parcel = simulator
.parcels
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.values()
.filter(|parcel| {
agent_position.x >= parcel.aabb_min.x
&& agent_position.x <= parcel.aabb_max.x
&& agent_position.y >= parcel.aabb_min.y
&& agent_position.y <= parcel.aabb_max.y
})
.min_by_key(|parcel| parcel.local_id)
.map(|parcel| crate::perception::ParcelSnapshot {
name: crate::perception::sanitize_untrusted(&parcel.name),
description: crate::perception::sanitize_untrusted(&parcel.desc),
area: Some(parcel.area),
category: Some(format!("{:?}", parcel.category)),
});
if let Some(data) = self.client.environment().environment() {
// Calling `environment()` reads the manager's last received value.
let _ = data.day_cycle();
}
let mut terrain_height = 0.0;
let terrain_observed = agent_position.x.is_finite()
&& agent_position.y.is_finite()
&& simulator
.terrain_height_at_point(
agent_position.x.floor() as i32,
agent_position.y.floor() as i32,
&mut terrain_height,
)
.unwrap_or(false);
let environment = Some(crate::perception::EnvironmentSnapshot {
water_height: Some(simulator.water_height),
terrain_height: terrain_observed.then_some(terrain_height),
sun_phase: None,
region_access: Some(format!("{:?}", simulator.access)),
});
let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client);
Ok(crate::perception::WorldSnapshot {
generation,
observed_unix_millis: crate::perception::unix_millis_now(),
region_id: simulator.region_id,
region_name: crate::perception::sanitize_untrusted(&simulator.name),
agent: crate::perception::AgentSnapshot {
id: self.agent.agent_id(),
position: Some(position),
health: Some(self.agent.health()),
sitting_on_local_id: (self.agent.sitting_on() != 0)
.then_some(self.agent.sitting_on()),
},
avatars,
avatars_truncated,
objects,
objects_truncated,
parcel,
environment,
inventory,
inventory_truncated,
landmarks,
})
}
}
#[cfg(feature = "live-grid")]
impl crate::perception::WorldSnapshotSource for LibremetaverseWorldSnapshotSource {
fn capture(
&self,
generation: u64,
cancellation: CancellationToken,
) -> crate::perception::SnapshotFuture<'_> {
let source = self.clone();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(crate::perception::PerceptionError::Cancelled);
}
let worker = tokio::task::spawn_blocking(move || source.capture_blocking(generation));
tokio::select! {
() = cancellation.cancelled() => Err(crate::perception::PerceptionError::Cancelled),
result = worker => result
.map_err(|_| crate::perception::PerceptionError::SourceUnavailable)?,
}
})
}
}
#[cfg(feature = "live-grid")]
fn native_position(value: libremetaverse_types::Vector3) -> crate::perception::WorldPosition {
crate::perception::WorldPosition {
x: f64::from(value.x),
y: f64::from(value.y),
z: f64::from(value.z),
}
}
#[cfg(feature = "live-grid")]
fn native_inventory_metadata(
client: &libremetaverse::GridClient,
) -> (
Vec<crate::perception::InventoryMetadata>,
Vec<crate::perception::LandmarkMetadata>,
bool,
) {
let Some(store) = client.inventory().store() else {
return (Vec::new(), Vec::new(), false);
};
let mut inventory = Vec::new();
let mut landmarks = Vec::new();
let mut pending = vec![(store.root_node(), String::new())];
let mut visited = 0_usize;
let mut truncated = false;
while let Some((node, parent_path)) = pending.pop() {
if visited >= 2_048 {
truncated = true;
break;
}
visited += 1;
let path = node.data().map_or_else(
|| parent_path.clone(),
|data| {
let base = data.inventory_base();
let name = crate::perception::sanitize_untrusted(&base.name());
let path = crate::perception::sanitize_untrusted(&if parent_path.is_empty() {
format!("/{name}")
} else {
format!("{parent_path}/{name}")
});
if let Some(item) = data.inventory_item() {
let modified_unix_millis = item
.creation_date()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|duration| u64::try_from(duration.as_millis()).ok());
inventory.push(crate::perception::InventoryMetadata {
id: base.uuid(),
name: name.clone(),
kind: format!("{:?}", item.inventory_type()),
path: path.clone(),
modified_unix_millis,
});
if item.asset_type() == libremetaverse_types::AssetType::Landmark {
landmarks.push(crate::perception::LandmarkMetadata {
id: base.uuid(),
name,
// Region is stored in the landmark asset body. The
// read-only metadata tool never downloads or decodes it.
region_id: None,
});
}
}
path
},
);
let mut children = node.nodes().values();
children.sort_by_key(|child| {
child.data().map_or_else(String::new, |value| {
crate::perception::sanitize_untrusted(&value.inventory_base().name())
})
});
for child in children.into_iter().rev() {
pending.push((child, path.clone()));
}
}
(inventory, landmarks, truncated || !pending.is_empty())
}
#[cfg(feature = "live-grid")]
@@ -289,6 +579,7 @@ struct LibremetaverseSession {
signals: mpsc::Receiver<crate::session::SessionSignal>,
subscriptions: Vec<libremetaverse_types::compat::Subscription>,
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
@@ -332,6 +623,9 @@ impl crate::session::GridSession for LibremetaverseSession {
if let Some(interaction) = &self.interaction {
let _ = interaction.try_disconnected();
}
if let Some(perception) = &self.perception {
perception.disconnected();
}
self.subscriptions.clear();
self.network
.native_logout_async(Some(cancellation))
@@ -399,6 +693,7 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
let (sender, signals) = mpsc::channel(8);
let disconnected_sender = sender.clone();
let disconnected_interaction = self.interaction.clone();
let disconnected_perception = self.perception.clone();
let disconnected_generation = Arc::clone(&self.delivery_generation);
let disconnected = self
.network
@@ -409,6 +704,9 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
if let Some(interaction) = &disconnected_interaction {
let _ = interaction.try_disconnected();
}
if let Some(perception) = &disconnected_perception {
perception.disconnected();
}
let kind = match event.reason() {
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout
| libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
@@ -428,6 +726,8 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
}));
let ready_sender = sender.clone();
let ready_interaction = self.interaction.clone();
let ready_perception = self.perception.clone();
let ready_network = self.network.clone();
let ready_agent = Arc::clone(&self.agent);
let ready_generation = Arc::clone(&self.delivery_generation);
let ready_once = Arc::new(AtomicBool::new(false));
@@ -442,10 +742,27 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
if let Some(interaction) = &ready_interaction {
let _ = interaction.try_connected(generation, ready_agent.agent_id());
}
if let (Some(perception), Some(simulator)) =
(&ready_perception, ready_network.native_current_sim())
{
let _ = perception.connected(generation, simulator.region_id);
}
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
}
}));
let mut subscriptions = vec![disconnected, ready];
if let Some(perception) = &self.perception {
let crossing_perception = perception.clone();
let crossing_network = self.network.clone();
subscriptions.push(self.network.native_subscribe_sim_changed(Arc::new(
move |_| {
if let Some(simulator) = crossing_network.native_current_sim() {
let _ =
crossing_perception.region_changed(generation, simulator.region_id);
}
},
)));
}
if let Some(interaction) = &self.interaction {
let chat_ingress = interaction.clone();
let chat_agent = Arc::clone(&self.agent);
@@ -484,6 +801,11 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
if let Some(interaction) = &self.interaction {
let _ = interaction.try_connected(generation, self.agent.agent_id());
}
if let (Some(perception), Some(simulator)) =
(&self.perception, self.network.native_current_sim())
{
let _ = perception.connected(generation, simulator.region_id);
}
let _ = sender.try_send(crate::session::SessionSignal::Ready);
}
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
@@ -492,6 +814,7 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
signals,
subscriptions,
interaction: self.interaction.clone(),
perception: self.perception.clone(),
delivery_generation: Arc::clone(&self.delivery_generation),
});
Ok(session)