feat(grid-agent): add bounded world perception tools (#124)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user