feat(grid-agent): add chat and IM interactions (#123)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m44s
CI / required (push) Failing after 2m41s

This commit is contained in:
2026-08-17 23:11:04 +00:00
parent e3ed39471b
commit 26fd2bf714
12 changed files with 3280 additions and 19 deletions

View File

@@ -2,6 +2,8 @@
use crate::policy::AuthorizedAction;
use crate::types::{GridEvent, GridEventKind, ToolCallOutcome};
#[cfg(feature = "live-grid")]
use libremetaverse_types::UUID;
use libremetaverse_types::compat::CancellationToken;
use std::error::Error;
use std::fmt;
@@ -11,6 +13,8 @@ use std::pin::Pin;
use std::sync::Arc;
#[cfg(feature = "live-grid")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(feature = "live-grid")]
use std::time::Duration;
use tokio::sync::mpsc;
pub type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
@@ -88,6 +92,8 @@ impl OfflineGridBackend {
#[cfg(feature = "live-grid")]
pub struct LibremetaverseClientOwner {
client: libremetaverse::GridClient,
agent: Arc<libremetaverse::AgentManager>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[cfg(feature = "live-grid")]
@@ -96,7 +102,7 @@ impl fmt::Debug for LibremetaverseClientOwner {
formatter
.debug_struct("LibremetaverseClientOwner")
.field("client", &"[NATIVE CLIENT REDACTED]")
.finish()
.finish_non_exhaustive()
}
}
@@ -106,10 +112,13 @@ impl fmt::Debug for LibremetaverseClientOwner {
#[derive(Clone)]
pub struct LibremetaverseSessionBackend {
network: libremetaverse::NetworkManager,
agent: Arc<libremetaverse::AgentManager>,
login_url: String,
first_name: String,
last_name: String,
password: crate::config::SecretString,
interaction: Option<crate::interaction::InteractionIngress>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[cfg(feature = "live-grid")]
@@ -138,7 +147,18 @@ impl LibremetaverseClientOwner {
.map_err(|_| BackendError::Configuration {
component: "libremetaverse client defaults",
})?;
Ok(Self { client })
let agent = Arc::new(
libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| {
BackendError::Configuration {
component: "native agent manager",
}
})?,
);
Ok(Self {
client,
agent,
delivery_generation: Arc::new(std::sync::RwLock::new(None)),
})
}
#[must_use]
@@ -147,9 +167,35 @@ impl LibremetaverseClientOwner {
}
/// Creates the supervised live-session adapter without logging in.
///
/// # Errors
///
/// Rejects a malformed avatar identity before any network operation.
pub fn session_backend(
&self,
connection: crate::config::GridConnection,
) -> Result<LibremetaverseSessionBackend, BackendError> {
self.session_backend_inner(connection, None)
}
/// Creates a supervised live session whose generation owns exactly one
/// native chat and IM subscription.
///
/// # Errors
///
/// Rejects a malformed avatar identity before any subscription or login.
pub fn session_backend_with_interaction(
&self,
connection: crate::config::GridConnection,
ingress: crate::interaction::InteractionIngress,
) -> Result<LibremetaverseSessionBackend, BackendError> {
self.session_backend_inner(connection, Some(ingress))
}
fn session_backend_inner(
&self,
connection: crate::config::GridConnection,
interaction: Option<crate::interaction::InteractionIngress>,
) -> Result<LibremetaverseSessionBackend, BackendError> {
let avatar = connection.avatar_name.trim();
let (first_name, last_name) = avatar
@@ -162,12 +208,78 @@ impl LibremetaverseClientOwner {
}
Ok(LibremetaverseSessionBackend {
network: self.client.network(),
agent: Arc::clone(&self.agent),
login_url: connection.login_url.expose_url().to_owned(),
first_name: first_name.to_owned(),
last_name: last_name.to_owned(),
password: connection.password,
interaction,
delivery_generation: Arc::clone(&self.delivery_generation),
})
}
#[must_use]
pub fn interaction_sink(&self) -> LibremetaverseInteractionSink {
LibremetaverseInteractionSink {
agent: Arc::clone(&self.agent),
delivery_generation: Arc::clone(&self.delivery_generation),
}
}
}
#[cfg(feature = "live-grid")]
#[derive(Clone)]
pub struct LibremetaverseInteractionSink {
agent: Arc<libremetaverse::AgentManager>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[cfg(feature = "live-grid")]
impl fmt::Debug for LibremetaverseInteractionSink {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LibremetaverseInteractionSink")
.field("agent", &"[NATIVE AGENT MANAGER]")
.finish_non_exhaustive()
}
}
#[cfg(feature = "live-grid")]
impl crate::interaction::InteractionSink for LibremetaverseInteractionSink {
fn deliver(
&self,
outbound: crate::interaction::OutboundInteraction,
cancellation: CancellationToken,
) -> crate::interaction::DeliveryFuture<'_> {
let result = if cancellation.is_cancellation_requested() {
Err(crate::interaction::InteractionDeliveryError::Disconnected)
} else {
let generation = self
.delivery_generation
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if *generation == Some(outbound.generation) {
match outbound.channel {
crate::interaction::InteractionChannel::PublicChat => self.agent.chat(
outbound.body.into_inner(),
0,
libremetaverse::ChatType::Normal,
Some(false),
),
crate::interaction::InteractionChannel::DirectIm => {
self.agent.instant_message_with_uuid_string(
outbound.recipient_id,
outbound.body.into_inner(),
)
}
}
.map_err(|_| crate::interaction::InteractionDeliveryError::Failed)
} else {
Err(crate::interaction::InteractionDeliveryError::Disconnected)
}
};
Box::pin(async move { result })
}
}
#[cfg(feature = "live-grid")]
@@ -176,6 +288,8 @@ struct LibremetaverseSession {
network: libremetaverse::NetworkManager,
signals: mpsc::Receiver<crate::session::SessionSignal>,
subscriptions: Vec<libremetaverse_types::compat::Subscription>,
interaction: Option<crate::interaction::InteractionIngress>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[cfg(feature = "live-grid")]
@@ -211,6 +325,13 @@ impl crate::session::GridSession for LibremetaverseSession {
cancellation: CancellationToken,
) -> crate::session::SessionFuture<'static, Result<(), crate::session::SessionFailure>> {
Box::pin(async move {
*self
.delivery_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
if let Some(interaction) = &self.interaction {
let _ = interaction.try_disconnected();
}
self.subscriptions.clear();
self.network
.native_logout_async(Some(cancellation))
@@ -226,6 +347,7 @@ impl crate::session::GridSession for LibremetaverseSession {
#[cfg(feature = "live-grid")]
impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
#[allow(clippy::too_many_lines)]
fn login(
&self,
generation: u64,
@@ -235,6 +357,10 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
Result<Box<dyn crate::session::GridSession>, crate::session::SessionFailure>,
> {
Box::pin(async move {
*self
.delivery_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
let mut params = self
.network
.native_default_login_params(
@@ -250,7 +376,7 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
)
})?;
params.uri.clone_from(&self.login_url);
params.start = "last".to_owned();
"last".clone_into(&mut params.start);
let logged_in = self
.network
.native_login(params, Some(cancellation))
@@ -272,11 +398,20 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
// adds only one disconnect and one readiness subscription.
let (sender, signals) = mpsc::channel(8);
let disconnected_sender = sender.clone();
let disconnected_interaction = self.interaction.clone();
let disconnected_generation = Arc::clone(&self.delivery_generation);
let disconnected = self
.network
.native_subscribe_disconnected(Arc::new(move |event| {
*disconnected_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
if let Some(interaction) = &disconnected_interaction {
let _ = interaction.try_disconnected();
}
let kind = match event.reason() {
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout => {
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout
| libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
crate::session::SessionFailureKind::TransientTransport
}
libremetaverse::NetworkManagerDisconnectType::ServerInitiated => {
@@ -285,9 +420,6 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
libremetaverse::NetworkManagerDisconnectType::SimShutdown => {
crate::session::SessionFailureKind::Maintenance
}
libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
crate::session::SessionFailureKind::TransientTransport
}
};
let _ =
disconnected_sender.try_send(crate::session::SessionSignal::Disconnected(
@@ -295,15 +427,43 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
));
}));
let ready_sender = sender.clone();
let ready_interaction = self.interaction.clone();
let ready_agent = Arc::clone(&self.agent);
let ready_generation = Arc::clone(&self.delivery_generation);
let ready_once = Arc::new(AtomicBool::new(false));
let callback_ready = Arc::clone(&ready_once);
let ready = self
.network
.native_subscribe_event_queue_running(Arc::new(move |_| {
if !callback_ready.swap(true, Ordering::AcqRel) {
*ready_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
if let Some(interaction) = &ready_interaction {
let _ = interaction.try_connected(generation, ready_agent.agent_id());
}
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
}
}));
let mut subscriptions = vec![disconnected, ready];
if let Some(interaction) = &self.interaction {
let chat_ingress = interaction.clone();
let chat_agent = Arc::clone(&self.agent);
subscriptions.push(self.agent.subscribe_chat_from_simulator(Arc::new(
move |event| {
if let Some(inbound) = native_public_interaction(&chat_agent, &event) {
let _ = chat_ingress.try_submit(inbound);
}
},
)));
let im_ingress = interaction.clone();
let im_agent = Arc::clone(&self.agent);
subscriptions.push(self.agent.subscribe_im(Arc::new(move |event| {
if let Some(inbound) = native_im_interaction(&im_agent, &event) {
let _ = im_ingress.try_submit(inbound);
}
})));
}
if !self.network.native_connected() {
let _ = sender.try_send(crate::session::SessionSignal::Disconnected(
crate::session::SessionFailure::new(
@@ -315,16 +475,24 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
.native_current_sim()
.and_then(|simulator| simulator.native_is_event_queue_running(None).ok())
.unwrap_or(false)
&& !ready_once.swap(true, Ordering::AcqRel)
{
if !ready_once.swap(true, Ordering::AcqRel) {
let _ = sender.try_send(crate::session::SessionSignal::Ready);
*self
.delivery_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
if let Some(interaction) = &self.interaction {
let _ = interaction.try_connected(generation, self.agent.agent_id());
}
let _ = sender.try_send(crate::session::SessionSignal::Ready);
}
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
generation,
network: self.network.clone(),
signals,
subscriptions: vec![disconnected, ready],
subscriptions,
interaction: self.interaction.clone(),
delivery_generation: Arc::clone(&self.delivery_generation),
});
Ok(session)
})
@@ -338,6 +506,134 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
}
}
#[cfg(feature = "live-grid")]
fn native_public_interaction(
agent: &libremetaverse::AgentManager,
event: &libremetaverse::ChatEventArgs,
) -> Option<crate::interaction::InboundInteraction> {
if event.audible_level() == libremetaverse::ChatAudibleLevel::Not
|| !matches!(
event.type_(),
libremetaverse::ChatType::Normal
| libremetaverse::ChatType::Whisper
| libremetaverse::ChatType::Shout
)
{
return None;
}
let source = match event.source_type() {
libremetaverse::ChatSourceType::Agent => crate::interaction::InboundSource::Avatar,
libremetaverse::ChatSourceType::Object => crate::interaction::InboundSource::Object,
libremetaverse::ChatSourceType::System => crate::interaction::InboundSource::System,
};
let source_id = event.source_id();
let owner_id = event.owner_id();
let message = event.message();
let name = event.from_name();
let bucket = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs()
/ 5;
let delivery_id = native_delivery_id(
"chat",
&[
&source_id.to_string(),
&owner_id.to_string(),
&bucket.to_string(),
&message,
],
);
crate::interaction::InboundInteraction::new(
delivery_id,
source_id,
name,
crate::interaction::InteractionChannel::PublicChat,
source,
crate::interaction::ImDialogKind::OneToOneMessage,
event.audible_level() == libremetaverse::ChatAudibleLevel::Fully,
native_muted(agent, source_id, owner_id),
message,
)
.ok()
}
#[cfg(feature = "live-grid")]
fn native_im_interaction(
agent: &libremetaverse::AgentManager,
event: &libremetaverse::InstantMessageEventArgs,
) -> Option<crate::interaction::InboundInteraction> {
let im = event.im();
let dialog = match im.dialog {
libremetaverse::InstantMessageDialog::MessageFromAgent if !im.group_im => {
crate::interaction::ImDialogKind::OneToOneMessage
}
libremetaverse::InstantMessageDialog::StartTyping
| libremetaverse::InstantMessageDialog::StopTyping => {
crate::interaction::ImDialogKind::Typing
}
libremetaverse::InstantMessageDialog::SessionSend => {
crate::interaction::ImDialogKind::GroupOrConference
}
_ => crate::interaction::ImDialogKind::Unsupported,
};
let timestamp = im
.timestamp
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis()
.to_string();
let delivery_id = native_delivery_id(
"im",
&[
&im.from_agent_id.to_string(),
&im.im_session_id.to_string(),
&timestamp,
&im.message,
],
);
crate::interaction::InboundInteraction::new(
delivery_id,
im.from_agent_id,
im.from_agent_name,
crate::interaction::InteractionChannel::DirectIm,
crate::interaction::InboundSource::Avatar,
dialog,
false,
native_muted(agent, im.from_agent_id, UUID::zero()),
im.message,
)
.ok()
}
#[cfg(feature = "live-grid")]
fn native_muted(agent: &libremetaverse::AgentManager, source: UUID, owner: UUID) -> bool {
agent
.mute_list
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.values()
.any(|entry| entry.id == source || (owner != UUID::zero() && entry.id == owner))
}
#[cfg(feature = "live-grid")]
fn native_delivery_id(prefix: &str, fields: &[&str]) -> String {
use sha2::Digest as _;
use std::fmt::Write as _;
let mut digest = sha2::Sha256::new();
for field in fields {
digest.update(field.as_bytes());
digest.update([0]);
}
let mut output = String::with_capacity(prefix.len() + 65);
output.push_str(prefix);
output.push('-');
for byte in digest.finalize() {
let _ = write!(output, "{byte:02x}");
}
output
}
#[cfg(feature = "live-grid")]
fn classify_native_login_failure(error_key: &str) -> crate::session::SessionFailure {
let normalized = error_key.trim().to_ascii_lowercase();