feat: complete stable OpenSim live interactions
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-22 00:48:58 +02:00
parent e4e84dcdcb
commit 2f5f03ac6f
11 changed files with 396 additions and 100 deletions

View File

@@ -5,8 +5,8 @@
use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture};
use crate::llm::{ToolDefinition, ToolSchema};
use crate::policy::{
AllowedOrigins, ApprovalRule, AuthorizedAction, Capability, Idempotency, OriginClass,
PolicyError, PolicyReasonCode, PolicyTool, ResourceCost, ResourceEstimator, Risk,
AllowedOrigins, ApprovalRule, AuthorizedAction, Capability, FixedCost, Idempotency,
OriginClass, PolicyError, PolicyReasonCode, PolicyTool, ResourceCost, ResourceEstimator, Risk,
};
use crate::types::{BoundedText, MAX_BODY_BYTES, MAX_OBSERVABLE_DETAIL_BYTES, ToolCallOutcome};
use libremetaverse_types::{UUID, compat::CancellationToken};
@@ -21,6 +21,7 @@ use std::time::Duration;
pub const BUILD_DRY_RUN_TOOL: &str = "build_object_dry_run";
pub const BUILD_EXECUTE_TOOL: &str = "build_object_execute";
pub const BUILD_CLEANUP_TOOL: &str = "build_object_cleanup";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BuildLimits {
@@ -126,6 +127,13 @@ pub struct BuildReceipt {
pub orphan_ids: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct BuildCleanupReceipt {
pub transaction_id: String,
pub deleted_object_ids: Vec<String>,
pub orphan_ids: Vec<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildObservationKind {
@@ -170,6 +178,7 @@ pub enum BuildError {
Cancelled,
TimedOut,
GridOperation,
UnknownTransaction,
CleanupIncomplete,
}
@@ -192,6 +201,7 @@ impl fmt::Display for BuildError {
Self::Cancelled => "build transaction was cancelled",
Self::TimedOut => "build step timed out",
Self::GridOperation => "native grid operation failed",
Self::UnknownTransaction => "unknown completed build transaction",
Self::CleanupIncomplete => {
"cleanup was incomplete; recoverable orphan IDs were recorded"
}
@@ -263,6 +273,7 @@ struct State {
cancelled: BTreeSet<String>,
observations: VecDeque<BuildObservation>,
orphans: BTreeMap<String, Vec<PrimReceipt>>,
completed: BTreeMap<String, Vec<PrimReceipt>>,
}
pub struct BuildService<G: BuildGrid> {
grid: Arc<G>,
@@ -283,6 +294,7 @@ impl<G: BuildGrid> BuildService<G> {
cancelled: BTreeSet::new(),
observations: VecDeque::new(),
orphans: BTreeMap::new(),
completed: BTreeMap::new(),
}),
})
}
@@ -384,7 +396,7 @@ impl<G: BuildGrid> BuildService<G> {
}
{
let mut state = lock(&self.state);
if state.active.is_some() {
if state.active.is_some() || state.completed.len() >= self.limits.max_observations {
return Err(BuildError::Busy);
}
state.active = Some(transaction.to_owned());
@@ -515,7 +527,7 @@ impl<G: BuildGrid> BuildService<G> {
}
.await;
if let Err(error) = mutation {
self.cleanup(transaction, correlation, &made).await;
let _ = self.cleanup(transaction, correlation, &made).await;
self.observe(
transaction,
correlation,
@@ -546,6 +558,9 @@ impl<G: BuildGrid> BuildService<G> {
None,
Some(root.object_id),
);
lock(&self.state)
.completed
.insert(transaction.to_owned(), made.clone());
Ok(BuildReceipt {
transaction_id: transaction.to_owned(),
root_object_id: root.object_id.to_string(),
@@ -553,7 +568,65 @@ impl<G: BuildGrid> BuildService<G> {
orphan_ids: Vec::new(),
})
}
async fn cleanup(&self, transaction: &str, correlation: &str, made: &[PrimReceipt]) {
pub async fn cleanup_completed(
&self,
transaction: &str,
correlation: &str,
cancellation: CancellationToken,
) -> Result<BuildCleanupReceipt, BuildError> {
if transaction.is_empty() || transaction.len() > 128 {
return Err(BuildError::InvalidIdentifier);
}
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let made = {
let mut state = lock(&self.state);
if state.active.is_some() {
return Err(BuildError::Busy);
}
let made = state
.completed
.get(transaction)
.cloned()
.ok_or(BuildError::UnknownTransaction)?;
state.active = Some(transaction.to_owned());
made
};
let orphaned = self.cleanup(transaction, correlation, &made).await;
let orphan_ids = orphaned
.iter()
.map(|receipt| receipt.object_id.to_string())
.collect::<Vec<_>>();
let orphan_set = orphaned
.iter()
.map(|receipt| receipt.object_id)
.collect::<BTreeSet<_>>();
let deleted_object_ids = made
.iter()
.filter(|receipt| !orphan_set.contains(&receipt.object_id))
.map(|receipt| receipt.object_id.to_string())
.collect();
let mut state = lock(&self.state);
state.active = None;
state.cancelled.remove(transaction);
if orphaned.is_empty() {
state.completed.remove(transaction);
} else {
state.completed.insert(transaction.to_owned(), orphaned);
}
Ok(BuildCleanupReceipt {
transaction_id: transaction.to_owned(),
deleted_object_ids,
orphan_ids,
})
}
async fn cleanup(
&self,
transaction: &str,
correlation: &str,
made: &[PrimReceipt],
) -> Vec<PrimReceipt> {
self.observe(
transaction,
correlation,
@@ -589,11 +662,15 @@ impl<G: BuildGrid> BuildService<G> {
);
}
}
if !orphaned.is_empty() {
lock(&self.state)
let mut state = lock(&self.state);
if orphaned.is_empty() {
state.orphans.remove(transaction);
} else {
state
.orphans
.insert(transaction.to_owned(), orphaned);
.insert(transaction.to_owned(), orphaned.clone());
}
orphaned
}
}
@@ -778,17 +855,26 @@ pub fn build_policy_tools(limits: BuildLimits) -> Result<Vec<PolicyTool>, Policy
};
let prim_properties = BTreeMap::from([
("id".into(), ToolSchema::String),
("parent".into(), ToolSchema::String),
(
"parent".into(),
ToolSchema::Nullable(Box::new(ToolSchema::String)),
),
("shape".into(), ToolSchema::String),
("position_millimeters".into(), vector(3)),
("scale_millimeters".into(), vector(3)),
("rotation_degrees".into(), vector(3)),
("color_rgba".into(), vector(4)),
("material".into(), ToolSchema::String),
("texture_inventory_id".into(), ToolSchema::String),
(
"texture_inventory_id".into(),
ToolSchema::Nullable(Box::new(ToolSchema::String)),
),
("name".into(), ToolSchema::String),
("description".into(), ToolSchema::String),
("script".into(), ToolSchema::String),
(
"script".into(),
ToolSchema::Nullable(Box::new(ToolSchema::String)),
),
]);
let prim_required = BTreeSet::from([
"id".into(),
@@ -825,6 +911,11 @@ pub fn build_policy_tools(limits: BuildLimits) -> Result<Vec<PolicyTool>, Policy
required: BTreeSet::from(["plan".into()]),
additional_properties: false,
};
let cleanup = ToolSchema::Object {
properties: BTreeMap::from([("transaction_id".into(), ToolSchema::String)]),
required: BTreeSet::from(["transaction_id".into()]),
additional_properties: false,
};
let origins = || AllowedOrigins::new([OriginClass::AuthorizedIm, OriginClass::LocalOperator]);
let max = ResourceCost {
tool_calls: 1,
@@ -877,6 +968,25 @@ pub fn build_policy_tools(limits: BuildLimits) -> Result<Vec<PolicyTool>, Policy
false,
Arc::new(BuildEstimator { limits }),
)?,
PolicyTool::new(
ToolDefinition {
name: BoundedText::new("tool.name", BUILD_CLEANUP_TOOL)?,
description: BoundedText::new(
"tool.description",
"Delete only objects retained from one completed build transaction and report any recoverable failures",
)?,
schema: cleanup,
mutating: true,
},
Capability::Build,
Risk::Build,
origins()?,
ResourceCost::one_call(),
Idempotency::NonIdempotent,
ApprovalRule::Never,
false,
Arc::new(FixedCost(ResourceCost::one_call())),
)?,
])
}
@@ -898,24 +1008,54 @@ impl<G: BuildGrid> AuthorizedToolBackend for BuildToolBackend<G> {
Box::pin(async move {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Args {
struct BuildArgs {
plan: BuildPlan,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CleanupArgs {
transaction_id: String,
}
let call_id = action.call().call_id.clone();
let transaction = format!("build-{}", action.authorization_id());
let result = serde_json::from_str::<Args>(action.call().arguments_json.as_str())
.map_err(|_| BuildError::InvalidPlan);
let result = match result {
Ok(args) if action.call().name.as_str() == BUILD_DRY_RUN_TOOL => self
.service
.dry_run(&args.plan, cancellation)
.await
.and_then(|v| serde_json::to_string(&v).map_err(|_| BuildError::InvalidPlan)),
Ok(args) if action.call().name.as_str() == BUILD_EXECUTE_TOOL => self
.service
.execute(&transaction, call_id.as_str(), args.plan, cancellation)
.await
.and_then(|v| serde_json::to_string(&v).map_err(|_| BuildError::InvalidPlan)),
let result = match action.call().name.as_str() {
BUILD_DRY_RUN_TOOL => {
match serde_json::from_str::<BuildArgs>(action.call().arguments_json.as_str()) {
Ok(args) => self
.service
.dry_run(&args.plan, cancellation)
.await
.and_then(|value| {
serde_json::to_string(&value).map_err(|_| BuildError::InvalidPlan)
}),
Err(_) => Err(BuildError::InvalidPlan),
}
}
BUILD_EXECUTE_TOOL => {
match serde_json::from_str::<BuildArgs>(action.call().arguments_json.as_str()) {
Ok(args) => self
.service
.execute(&transaction, call_id.as_str(), args.plan, cancellation)
.await
.and_then(|value| {
serde_json::to_string(&value).map_err(|_| BuildError::InvalidPlan)
}),
Err(_) => Err(BuildError::InvalidPlan),
}
}
BUILD_CLEANUP_TOOL => {
match serde_json::from_str::<CleanupArgs>(action.call().arguments_json.as_str())
{
Ok(args) => self
.service
.cleanup_completed(&args.transaction_id, call_id.as_str(), cancellation)
.await
.and_then(|value| {
serde_json::to_string(&value).map_err(|_| BuildError::InvalidPlan)
}),
Err(_) => Err(BuildError::InvalidPlan),
}
}
_ => Err(BuildError::InvalidPlan),
};
Ok(match result {
@@ -1016,32 +1156,45 @@ impl BuildGrid for LibremetaverseBuildGrid {
if simulator.region_id.to_string() != region {
return Err(BuildError::OutOfRegion);
}
let parcels = self.client.parcels();
let base = self.agent.sim_position();
let reported_bounds_contain_agent =
base.x <= simulator.size_x as f32 && base.y <= simulator.size_y as f32;
for offset in positions {
let point = libremetaverse_types::Vector3 {
x: base.x + offset[0] as f32 / 1000.0,
y: base.y + offset[1] as f32 / 1000.0,
z: base.z + offset[2] as f32 / 1000.0,
};
if !(0.0..=256.0).contains(&point.x) || !(0.0..=256.0).contains(&point.y) {
if point.x < 0.0
|| point.y < 0.0
|| (reported_bounds_contain_agent
&& (point.x > simulator.size_x as f32 || point.y > simulator.size_y as f32))
{
return Err(BuildError::OutOfRegion);
}
let local = parcels
.get_parcel_local_id(simulator.clone(), point)
.map_err(|_| BuildError::LandDenied)?;
if local == 0 {
return Err(BuildError::LandDenied);
}
let allowed = simulator
let parcels = simulator
.parcels
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&local)
.is_some_and(|parcel| {
parcel.owner_id == self.agent.agent_id()
|| parcel.flags.0 & libremetaverse::ParcelFlags::CREATE_OBJECTS.0 != 0
});
.unwrap_or_else(std::sync::PoisonError::into_inner);
let parcel = parcels
.values()
.filter(|parcel| {
point.x >= parcel.aabb_min.x
&& point.x <= parcel.aabb_max.x
&& point.y >= parcel.aabb_min.y
&& point.y <= parcel.aabb_max.y
})
.min_by_key(|parcel| parcel.local_id);
// Some OpenSim varregions expose only the legacy 256 m parcel
// cache; the server remains authoritative for the bounded rez.
let allowed = parcel.map_or(!reported_bounds_contain_agent, |parcel| {
parcel.owner_id == self.agent.agent_id()
|| parcel.flags.0 & libremetaverse::ParcelFlags::CREATE_OBJECTS.0 != 0
|| (parcel.group_id != UUID::zero()
&& parcel.group_id == self.agent.active_group()
&& parcel.flags.0 & libremetaverse::ParcelFlags::CREATE_GROUP_OBJECTS.0
!= 0)
});
if !allowed {
return Err(BuildError::LandDenied);
}
@@ -1086,6 +1239,7 @@ impl BuildGrid for LibremetaverseBuildGrid {
}
let simulator = self.simulator()?;
let target = self.world_position(&prim);
let z_tolerance = prim.scale_millimeters[2] as f32 / 2_000.0 + 0.1;
let (sender, receiver) = tokio::sync::oneshot::channel();
let sender = Arc::new(Mutex::new(Some(sender)));
let callback_sender = Arc::clone(&sender);
@@ -1094,7 +1248,7 @@ impl BuildGrid for LibremetaverseBuildGrid {
if event.is_new()
&& (found.position.x - target.x).abs() < 0.05
&& (found.position.y - target.y).abs() < 0.05
&& (found.position.z - target.z).abs() < 0.05
&& (found.position.z - target.z).abs() <= z_tolerance
&& let Some(sender) = lock(&callback_sender).take()
{
let _ = sender.send(PrimReceipt {
@@ -1131,7 +1285,7 @@ impl BuildGrid for LibremetaverseBuildGrid {
.add_prim_with_simulator_construction_data_uuid_vector3_vector3_quaternion(
simulator,
construction,
UUID::zero(),
self.agent.active_group(),
target,
scale,
rotation,