Files
MetaCrate/crates/metacrate-grid-agent/src/build.rs
Chili Palmer d85f7d22f2
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled
Fix varregion scene capture and legacy rendering
2026-08-23 10:18:19 +02:00

1479 lines
52 KiB
Rust

//! Authorized, validated, transactional linked-primitive construction.
#![allow(clippy::missing_errors_doc)]
use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture};
use crate::llm::{ToolDefinition, ToolSchema};
use crate::policy::{
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};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
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 {
pub max_prims: usize,
pub approval_free_prims: usize,
pub max_script_bytes: usize,
pub max_dimension_millimeters: u32,
pub max_distance_millimeters: u32,
pub step_timeout: Duration,
pub cleanup_timeout: Duration,
pub max_observations: usize,
}
impl Default for BuildLimits {
fn default() -> Self {
Self {
max_prims: 16,
approval_free_prims: 4,
max_script_bytes: 32 * 1024,
max_dimension_millimeters: 10_000,
max_distance_millimeters: 20_000,
step_timeout: Duration::from_secs(20),
cleanup_timeout: Duration::from_secs(20),
max_observations: 256,
}
}
}
impl BuildLimits {
fn valid(self) -> bool {
(1..=256).contains(&self.max_prims)
&& self.approval_free_prims <= self.max_prims
&& (256..=64 * 1024).contains(&self.max_script_bytes)
&& (100..=64_000).contains(&self.max_dimension_millimeters)
&& (1_000..=256_000).contains(&self.max_distance_millimeters)
&& !self.step_timeout.is_zero()
&& self.step_timeout <= Duration::from_mins(2)
&& !self.cleanup_timeout.is_zero()
&& self.cleanup_timeout <= Duration::from_mins(2)
&& (16..=4_096).contains(&self.max_observations)
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum BuildShape {
Box,
Cylinder,
Prism,
Sphere,
Torus,
Tube,
Ring,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BuildPrim {
pub id: String,
pub parent: Option<String>,
pub shape: BuildShape,
pub position_millimeters: [i32; 3],
pub scale_millimeters: [u32; 3],
pub rotation_degrees: [f64; 3],
pub color_rgba: [f64; 4],
pub material: String,
pub texture_inventory_id: Option<String>,
pub name: String,
pub description: String,
pub script: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BuildPlan {
pub version: u32,
pub region_id: String,
pub prims: Vec<BuildPrim>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct BuildValidation {
pub prim_count: usize,
pub link_count: usize,
pub script_bytes: usize,
pub inventory_operations: usize,
pub approval_required: bool,
pub estimated_max_seconds: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PrimReceipt {
pub plan_id: String,
pub object_id: UUID,
pub local_id: u32,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct BuildReceipt {
pub transaction_id: String,
pub root_object_id: String,
pub object_ids: Vec<String>,
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 {
Validated,
Creating,
Created,
Configured,
Linked,
ScriptInserted,
Cleaning,
Cleaned,
Orphaned,
Completed,
Cancelled,
Failed,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct BuildObservation {
pub transaction_id: String,
pub correlation_id: String,
pub kind: BuildObservationKind,
pub plan_id: Option<String>,
pub object_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BuildError {
UnsafeLimits,
InvalidPlan,
UnsupportedVersion,
TooManyPrims,
InvalidIdentifier,
InvalidGeometry,
OutOfRegion,
InvalidTopology,
InvalidMaterial,
InvalidTexture,
InvalidScript,
LandDenied,
Busy,
Cancelled,
TimedOut,
GridOperation,
UnknownTransaction,
CleanupIncomplete,
}
impl fmt::Display for BuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::UnsafeLimits => "unsafe build limits",
Self::InvalidPlan => "invalid build plan",
Self::UnsupportedVersion => "unsupported build plan version",
Self::TooManyPrims => "build exceeds primitive limit",
Self::InvalidIdentifier => "invalid build identifier or text",
Self::InvalidGeometry => "invalid or unbounded geometry",
Self::OutOfRegion => "build is outside the current region or distance limit",
Self::InvalidTopology => "build link topology is invalid",
Self::InvalidMaterial => "unsupported material",
Self::InvalidTexture => "texture is not an owned inventory UUID",
Self::InvalidScript => "script failed local LSL validation",
Self::LandDenied => "agent lacks build rights at the requested location",
Self::Busy => "another build transaction is active",
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"
}
})
}
}
impl std::error::Error for BuildError {}
pub type BuildFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, BuildError>> + Send + 'a>>;
pub trait BuildGrid: Send + Sync + 'static {
fn validate_land(
&self,
region_id: &str,
positions: &[[i32; 3]],
cancellation: CancellationToken,
) -> BuildFuture<'_, ()>;
fn texture_is_owned(
&self,
texture: UUID,
cancellation: CancellationToken,
) -> BuildFuture<'_, bool>;
fn create_prim(
&self,
transaction_id: &str,
prim: &BuildPrim,
cancellation: CancellationToken,
) -> BuildFuture<'_, PrimReceipt>;
fn configure_prim(
&self,
receipt: &PrimReceipt,
prim: &BuildPrim,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()>;
fn link_prims(
&self,
parent: &PrimReceipt,
child: &PrimReceipt,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()>;
fn insert_script(
&self,
receipt: &PrimReceipt,
source: &str,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()>;
fn confirm_prim(
&self,
receipt: &PrimReceipt,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()>;
fn delete_owned_prim(
&self,
receipt: &PrimReceipt,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()>;
}
/// Type-erased cancellation hook used by the operator control plane.
pub trait BuildControl: Send + Sync {
fn cancel_build(&self, transaction_id: &str) -> bool;
fn active_build(&self) -> Option<String>;
fn build_orphans(&self) -> Vec<String>;
fn build_progress(&self) -> Option<String>;
}
struct State {
active: Option<String>,
cancelled: BTreeSet<String>,
observations: VecDeque<BuildObservation>,
orphans: BTreeMap<String, Vec<PrimReceipt>>,
completed: BTreeMap<String, Vec<PrimReceipt>>,
}
pub struct BuildService<G: BuildGrid> {
grid: Arc<G>,
limits: BuildLimits,
state: Mutex<State>,
}
impl<G: BuildGrid> BuildService<G> {
pub fn new(grid: Arc<G>, limits: BuildLimits) -> Result<Self, BuildError> {
if !limits.valid() {
return Err(BuildError::UnsafeLimits);
}
Ok(Self {
grid,
limits,
state: Mutex::new(State {
active: None,
cancelled: BTreeSet::new(),
observations: VecDeque::new(),
orphans: BTreeMap::new(),
completed: BTreeMap::new(),
}),
})
}
#[must_use]
pub fn observations(&self) -> Vec<BuildObservation> {
lock(&self.state).observations.iter().cloned().collect()
}
#[must_use]
pub fn orphan_ids(&self, transaction: &str) -> Vec<UUID> {
lock(&self.state)
.orphans
.get(transaction)
.map_or_else(Vec::new, |v| v.iter().map(|r| r.object_id).collect())
}
pub fn cancel(&self, transaction: &str) -> bool {
let mut state = lock(&self.state);
if state.active.as_deref() == Some(transaction) {
state.cancelled.insert(transaction.to_owned());
true
} else {
false
}
}
fn observe(
&self,
transaction: &str,
correlation: &str,
kind: BuildObservationKind,
plan: Option<&str>,
object: Option<UUID>,
) {
let mut state = lock(&self.state);
if state.observations.len() == self.limits.max_observations {
state.observations.pop_front();
}
state.observations.push_back(BuildObservation {
transaction_id: transaction.to_owned(),
correlation_id: correlation.to_owned(),
kind,
plan_id: plan.map(str::to_owned),
object_id: object.map(|id| id.to_string()),
});
}
fn cancelled(&self, transaction: &str, token: &CancellationToken) -> bool {
token.is_cancellation_requested() || lock(&self.state).cancelled.contains(transaction)
}
pub fn validate(&self, plan: &BuildPlan) -> Result<BuildValidation, BuildError> {
validate_plan(plan, self.limits)
}
pub async fn dry_run(
&self,
plan: &BuildPlan,
cancellation: CancellationToken,
) -> Result<BuildValidation, BuildError> {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let validation = self.validate(plan)?;
timed(
self.limits.step_timeout,
self.grid.validate_land(
&plan.region_id,
&plan
.prims
.iter()
.map(|p| p.position_millimeters)
.collect::<Vec<_>>(),
cancellation.clone(),
),
)
.await?;
for texture in plan
.prims
.iter()
.filter_map(|p| p.texture_inventory_id.as_deref())
{
let id = UUID::new_with_string(texture.to_owned())
.map_err(|_| BuildError::InvalidTexture)?;
if !timed(
self.limits.step_timeout,
self.grid.texture_is_owned(id, cancellation.clone()),
)
.await?
{
return Err(BuildError::InvalidTexture);
}
}
Ok(validation)
}
pub async fn execute(
&self,
transaction: &str,
correlation: &str,
plan: BuildPlan,
cancellation: CancellationToken,
) -> Result<BuildReceipt, BuildError> {
if transaction.is_empty() || transaction.len() > 128 {
return Err(BuildError::InvalidIdentifier);
}
{
let mut state = lock(&self.state);
if state.active.is_some() || state.completed.len() >= self.limits.max_observations {
return Err(BuildError::Busy);
}
state.active = Some(transaction.to_owned());
state.cancelled.remove(transaction);
state.orphans.remove(transaction);
}
let result = self
.execute_inner(transaction, correlation, &plan, cancellation.clone())
.await;
let mut state = lock(&self.state);
state.active = None;
state.cancelled.remove(transaction);
result
}
#[allow(clippy::too_many_lines)]
async fn execute_inner(
&self,
transaction: &str,
correlation: &str,
plan: &BuildPlan,
cancellation: CancellationToken,
) -> Result<BuildReceipt, BuildError> {
self.dry_run(plan, cancellation.clone()).await?;
self.observe(
transaction,
correlation,
BuildObservationKind::Validated,
None,
None,
);
let mut made = Vec::new();
let mut by_id = BTreeMap::new();
let mutation = async {
for prim in &plan.prims {
if self.cancelled(transaction, &cancellation) {
return Err(BuildError::Cancelled);
}
self.observe(
transaction,
correlation,
BuildObservationKind::Creating,
Some(&prim.id),
None,
);
let receipt = timed(
self.limits.step_timeout,
self.grid
.create_prim(transaction, prim, cancellation.clone()),
)
.await?;
made.push(receipt.clone());
by_id.insert(prim.id.clone(), receipt.clone());
self.observe(
transaction,
correlation,
BuildObservationKind::Created,
Some(&prim.id),
Some(receipt.object_id),
);
timed(
self.limits.step_timeout,
self.grid.confirm_prim(&receipt, cancellation.clone()),
)
.await?;
timed(
self.limits.step_timeout,
self.grid
.configure_prim(&receipt, prim, cancellation.clone()),
)
.await?;
timed(
self.limits.step_timeout,
self.grid.confirm_prim(&receipt, cancellation.clone()),
)
.await?;
self.observe(
transaction,
correlation,
BuildObservationKind::Configured,
Some(&prim.id),
Some(receipt.object_id),
);
if let Some(source) = &prim.script {
timed(
self.limits.step_timeout,
self.grid
.insert_script(&receipt, source, cancellation.clone()),
)
.await?;
timed(
self.limits.step_timeout,
self.grid.confirm_prim(&receipt, cancellation.clone()),
)
.await?;
self.observe(
transaction,
correlation,
BuildObservationKind::ScriptInserted,
Some(&prim.id),
Some(receipt.object_id),
);
}
}
for prim in &plan.prims {
if let Some(parent_id) = &prim.parent {
let parent = by_id.get(parent_id).ok_or(BuildError::InvalidTopology)?;
let child = by_id.get(&prim.id).ok_or(BuildError::InvalidTopology)?;
timed(
self.limits.step_timeout,
self.grid.link_prims(parent, child, cancellation.clone()),
)
.await?;
timed(
self.limits.step_timeout,
self.grid.confirm_prim(child, cancellation.clone()),
)
.await?;
self.observe(
transaction,
correlation,
BuildObservationKind::Linked,
Some(&prim.id),
Some(child.object_id),
);
}
}
Ok::<(), BuildError>(())
}
.await;
if let Err(error) = mutation {
let _ = self.cleanup(transaction, correlation, &made).await;
self.observe(
transaction,
correlation,
if error == BuildError::Cancelled {
BuildObservationKind::Cancelled
} else {
BuildObservationKind::Failed
},
None,
None,
);
return Err(if self.orphan_ids(transaction).is_empty() {
error
} else {
BuildError::CleanupIncomplete
});
}
let root = plan
.prims
.iter()
.find(|p| p.parent.is_none())
.and_then(|p| by_id.get(&p.id))
.ok_or(BuildError::InvalidTopology)?;
self.observe(
transaction,
correlation,
BuildObservationKind::Completed,
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(),
object_ids: made.iter().map(|r| r.object_id.to_string()).collect(),
orphan_ids: Vec::new(),
})
}
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,
BuildObservationKind::Cleaning,
None,
None,
);
let mut orphaned = Vec::new();
for receipt in made.iter().rev() {
if timed(
self.limits.cleanup_timeout,
self.grid
.delete_owned_prim(receipt, CancellationToken::default()),
)
.await
.is_ok()
{
self.observe(
transaction,
correlation,
BuildObservationKind::Cleaned,
Some(&receipt.plan_id),
Some(receipt.object_id),
);
} else {
orphaned.push(receipt.clone());
self.observe(
transaction,
correlation,
BuildObservationKind::Orphaned,
Some(&receipt.plan_id),
Some(receipt.object_id),
);
}
}
let mut state = lock(&self.state);
if orphaned.is_empty() {
state.orphans.remove(transaction);
} else {
state
.orphans
.insert(transaction.to_owned(), orphaned.clone());
}
orphaned
}
}
impl<G: BuildGrid> BuildControl for BuildService<G> {
fn cancel_build(&self, transaction_id: &str) -> bool {
self.cancel(transaction_id)
}
fn active_build(&self) -> Option<String> {
lock(&self.state).active.clone()
}
fn build_orphans(&self) -> Vec<String> {
lock(&self.state)
.orphans
.values()
.flatten()
.map(|receipt| receipt.object_id.to_string())
.collect()
}
fn build_progress(&self) -> Option<String> {
lock(&self.state)
.observations
.back()
.map(|observation| format!("{:?}", observation.kind).to_ascii_lowercase())
}
}
async fn timed<T>(duration: Duration, future: BuildFuture<'_, T>) -> Result<T, BuildError> {
tokio::time::timeout(duration, future)
.await
.map_err(|_| BuildError::TimedOut)?
}
fn lock<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
value
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[allow(clippy::too_many_lines)]
fn validate_plan(plan: &BuildPlan, limits: BuildLimits) -> Result<BuildValidation, BuildError> {
if plan.version != 1 {
return Err(BuildError::UnsupportedVersion);
}
if plan.region_id.is_empty() || plan.region_id.len() > 128 || plan.prims.is_empty() {
return Err(BuildError::InvalidPlan);
}
if plan.prims.len() > limits.max_prims {
return Err(BuildError::TooManyPrims);
}
let mut ids = BTreeSet::new();
let mut script_bytes = 0usize;
let mut inventory = 0usize;
for prim in &plan.prims {
if prim.id.is_empty()
|| prim.id.len() > 64
|| !prim
.id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|| !ids.insert(prim.id.clone())
|| prim.name.is_empty()
|| prim.name.len() > 63
|| prim.description.len() > 127
{
return Err(BuildError::InvalidIdentifier);
}
if prim
.scale_millimeters
.iter()
.any(|v| *v == 0 || *v > limits.max_dimension_millimeters)
|| prim
.position_millimeters
.iter()
.any(|v| v.unsigned_abs() > limits.max_distance_millimeters)
|| prim
.rotation_degrees
.iter()
.any(|v| !v.is_finite() || v.abs() > 360.0)
|| prim
.color_rgba
.iter()
.any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
{
return Err(BuildError::InvalidGeometry);
}
if !matches!(
prim.material.as_str(),
"wood" | "metal" | "glass" | "stone" | "plastic" | "rubber" | "flesh"
) {
return Err(BuildError::InvalidMaterial);
}
if let Some(texture) = &prim.texture_inventory_id {
UUID::new_with_string(texture.clone()).map_err(|_| BuildError::InvalidTexture)?;
inventory += 1;
}
if let Some(script) = &prim.script {
let candidate = crate::script_delivery::GeneratedScript {
name: prim.name.clone(),
description: if prim.description.is_empty() {
"Object script".to_owned()
} else {
prim.description.clone()
},
source: script.clone(),
};
if crate::script_delivery::validate_script(&candidate, limits.max_script_bytes).is_err()
{
return Err(BuildError::InvalidScript);
}
script_bytes += script.len();
inventory += 1;
}
}
let roots = plan.prims.iter().filter(|p| p.parent.is_none()).count();
if roots != 1 {
return Err(BuildError::InvalidTopology);
}
for prim in &plan.prims {
if let Some(parent) = &prim.parent {
if parent == &prim.id || !ids.contains(parent) {
return Err(BuildError::InvalidTopology);
}
let mut cursor = Some(parent.as_str());
let mut seen = BTreeSet::new();
while let Some(id) = cursor {
if !seen.insert(id) {
return Err(BuildError::InvalidTopology);
}
cursor = plan
.prims
.iter()
.find(|p| p.id == id)
.and_then(|p| p.parent.as_deref());
}
}
}
Ok(BuildValidation {
prim_count: plan.prims.len(),
link_count: plan.prims.len() - 1,
script_bytes,
inventory_operations: inventory,
approval_required: plan.prims.len() > limits.approval_free_prims,
estimated_max_seconds: u64::try_from(plan.prims.len())
.unwrap_or(u64::MAX)
.saturating_mul(6)
.saturating_sub(1)
.saturating_mul(limits.step_timeout.as_secs().max(1)),
})
}
#[derive(Clone)]
struct BuildEstimator {
limits: BuildLimits,
}
impl ResourceEstimator for BuildEstimator {
fn estimate(&self, arguments: &Value) -> Result<ResourceCost, PolicyReasonCode> {
let plan: BuildPlan = serde_json::from_value(
arguments
.get("plan")
.cloned()
.ok_or(PolicyReasonCode::InvalidArguments)?,
)
.map_err(|_| PolicyReasonCode::InvalidArguments)?;
let valid =
validate_plan(&plan, self.limits).map_err(|_| PolicyReasonCode::InvalidArguments)?;
Ok(ResourceCost {
tool_calls: 1,
inventory_operations: valid.inventory_operations as u64,
build_prims: valid.prim_count as u64,
..ResourceCost::default()
})
}
}
#[allow(clippy::too_many_lines)]
pub fn build_policy_tools(limits: BuildLimits) -> Result<Vec<PolicyTool>, PolicyError> {
if !limits.valid() {
return Err(PolicyError::InvalidRegistration);
}
let vector = |length| ToolSchema::Array {
items: Box::new(ToolSchema::Number),
max_items: length,
};
let prim_properties = BTreeMap::from([
("id".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::Nullable(Box::new(ToolSchema::String)),
),
("name".into(), ToolSchema::String),
("description".into(), ToolSchema::String),
(
"script".into(),
ToolSchema::Nullable(Box::new(ToolSchema::String)),
),
]);
let prim_required = BTreeSet::from([
"id".into(),
"shape".into(),
"position_millimeters".into(),
"scale_millimeters".into(),
"rotation_degrees".into(),
"color_rgba".into(),
"material".into(),
"name".into(),
"description".into(),
]);
let plan_schema = ToolSchema::Object {
properties: BTreeMap::from([
("version".into(), ToolSchema::Integer),
("region_id".into(), ToolSchema::String),
(
"prims".into(),
ToolSchema::Array {
items: Box::new(ToolSchema::Object {
properties: prim_properties,
required: prim_required,
additional_properties: false,
}),
max_items: limits.max_prims,
},
),
]),
required: BTreeSet::from(["version".into(), "region_id".into(), "prims".into()]),
additional_properties: false,
};
let plan = ToolSchema::Object {
properties: BTreeMap::from([("plan".into(), plan_schema)]),
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,
inventory_operations: (limits.max_prims * 2) as u64,
build_prims: limits.max_prims as u64,
..ResourceCost::default()
};
let threshold = ResourceCost {
tool_calls: 1,
inventory_operations: max.inventory_operations,
build_prims: limits.approval_free_prims as u64,
..ResourceCost::default()
};
Ok(vec![
PolicyTool::new(
ToolDefinition {
name: BoundedText::new("tool.name", BUILD_DRY_RUN_TOOL)?,
description: BoundedText::new(
"tool.description",
"Validate a versioned bounded linked-primitive build and return its exact resource estimate without mutation",
)?,
schema: plan.clone(),
mutating: false,
},
Capability::Informational,
Risk::ReadOnly,
origins()?,
max,
Idempotency::Idempotent,
ApprovalRule::Never,
false,
Arc::new(BuildEstimator { limits }),
)?,
PolicyTool::new(
ToolDefinition {
name: BoundedText::new("tool.name", BUILD_EXECUTE_TOOL)?,
description: BoundedText::new(
"tool.description",
"Execute one validated transactional linked-primitive build; larger plans receive an autonomous safety review",
)?,
schema: plan,
mutating: true,
},
Capability::Build,
Risk::Build,
origins()?,
max,
Idempotency::NonIdempotent,
ApprovalRule::WhenExceeds(threshold),
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())),
)?,
])
}
pub struct BuildToolBackend<G: BuildGrid> {
service: Arc<BuildService<G>>,
}
impl<G: BuildGrid> BuildToolBackend<G> {
#[must_use]
pub fn new(service: Arc<BuildService<G>>) -> Self {
Self { service }
}
}
impl<G: BuildGrid> AuthorizedToolBackend for BuildToolBackend<G> {
fn apply(
&self,
action: AuthorizedAction,
cancellation: CancellationToken,
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
Box::pin(async move {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
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 = 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 {
Ok(value) => ToolCallOutcome::Completed {
call_id,
result: BoundedText::<MAX_BODY_BYTES>::new("build.result", value).map_err(
|_| BackendError::Operation {
operation: "bounded build result",
},
)?,
},
Err(error) => {
let orphans = self.service.orphan_ids(&transaction);
let reason = if orphans.is_empty() {
error.to_string()
} else {
format!(
"{error}; orphan_ids={}",
orphans
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",")
)
};
ToolCallOutcome::Rejected {
call_id,
reason: BoundedText::<MAX_OBSERVABLE_DETAIL_BYTES>::new(
"build.rejection",
reason,
)
.map_err(|_| BackendError::Operation {
operation: "bounded build rejection",
})?,
}
}
})
})
}
}
/// Live adapter over the original compatibility surface. All orchestration,
/// policy, validation, correlation, and cleanup ownership remain in `MetaCrate`.
#[cfg(feature = "live-grid")]
pub struct LibremetaverseBuildGrid {
client: libremetaverse::GridClient,
agent: Arc<libremetaverse::AgentManager>,
objects: libremetaverse::ObjectManager,
inventory: libremetaverse::InventoryManager,
resolved_textures: Mutex<BTreeMap<UUID, UUID>>,
}
#[cfg(feature = "live-grid")]
#[allow(clippy::cast_precision_loss)]
impl LibremetaverseBuildGrid {
#[must_use]
pub fn new(owner: &crate::backend::LibremetaverseClientOwner) -> Self {
Self {
client: owner.client().clone(),
agent: owner.agent(),
objects: owner.client().objects(),
inventory: owner.client().inventory(),
resolved_textures: Mutex::new(BTreeMap::new()),
}
}
fn simulator(&self) -> Result<libremetaverse::Simulator, BuildError> {
self.client
.network()
.current_sim()
.ok_or(BuildError::GridOperation)
}
fn world_position(&self, prim: &BuildPrim) -> libremetaverse_types::Vector3 {
let base = self.agent.sim_position();
libremetaverse_types::Vector3 {
x: base.x + prim.position_millimeters[0] as f32 / 1000.0,
y: base.y + prim.position_millimeters[1] as f32 / 1000.0,
z: base.z + prim.position_millimeters[2] as f32 / 1000.0,
}
}
}
#[cfg(feature = "live-grid")]
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
impl BuildGrid for LibremetaverseBuildGrid {
fn validate_land(
&self,
region_id: &str,
positions: &[[i32; 3]],
cancellation: CancellationToken,
) -> BuildFuture<'_, ()> {
let region = region_id.to_owned();
let positions = positions.to_vec();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let simulator = self.simulator()?;
if simulator.region_id.to_string() != region {
return Err(BuildError::OutOfRegion);
}
let base = self.agent.sim_position();
let (region_size_x, region_size_y) = simulator.region_size();
let reported_bounds_contain_agent =
base.x <= region_size_x as f32 && base.y <= region_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 point.x < 0.0
|| point.y < 0.0
|| (reported_bounds_contain_agent
&& (point.x > region_size_x as f32 || point.y > region_size_y as f32))
{
return Err(BuildError::OutOfRegion);
}
let parcels = simulator
.parcels
.read()
.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);
}
}
Ok(())
})
}
fn texture_is_owned(
&self,
texture: UUID,
cancellation: CancellationToken,
) -> BuildFuture<'_, bool> {
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let store = self.inventory.store().ok_or(BuildError::InvalidTexture)?;
let node = store
.get_node_for(texture)
.map_err(|_| BuildError::InvalidTexture)?;
let object = node.data().ok_or(BuildError::InvalidTexture)?;
let item = object.inventory_item().ok_or(BuildError::InvalidTexture)?;
if item.asset_type() != libremetaverse_types::AssetType::Texture
|| object.inventory_base().owner_id() != self.agent.agent_id()
{
return Ok(false);
}
lock(&self.resolved_textures).insert(texture, item.asset_uuid());
Ok(true)
})
}
fn create_prim(
&self,
_: &str,
prim: &BuildPrim,
cancellation: CancellationToken,
) -> BuildFuture<'_, PrimReceipt> {
let prim = prim.clone();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
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);
let _subscription = self.objects.subscribe_object_update(Arc::new(move |event| {
let found = event.prim();
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() <= z_tolerance
&& let Some(sender) = lock(&callback_sender).take()
{
let _ = sender.send(PrimReceipt {
plan_id: prim.id.clone(),
object_id: found.id,
local_id: found.local_id,
});
}
}));
let shape = match prim.shape {
BuildShape::Box => libremetaverse_types::PrimType::Box,
BuildShape::Cylinder => libremetaverse_types::PrimType::Cylinder,
BuildShape::Prism => libremetaverse_types::PrimType::Prism,
BuildShape::Sphere => libremetaverse_types::PrimType::Sphere,
BuildShape::Torus => libremetaverse_types::PrimType::Torus,
BuildShape::Tube => libremetaverse_types::PrimType::Tube,
BuildShape::Ring => libremetaverse_types::PrimType::Ring,
};
let construction = libremetaverse::ObjectManager::build_basic_shape(shape)
.map_err(|_| BuildError::GridOperation)?;
let scale = libremetaverse_types::Vector3 {
x: prim.scale_millimeters[0] as f32 / 1000.0,
y: prim.scale_millimeters[1] as f32 / 1000.0,
z: prim.scale_millimeters[2] as f32 / 1000.0,
};
let rotation =
libremetaverse_types::Quaternion::create_from_eulers_with_single_single_single(
prim.rotation_degrees[0].to_radians() as f32,
prim.rotation_degrees[1].to_radians() as f32,
prim.rotation_degrees[2].to_radians() as f32,
)
.map_err(|_| BuildError::InvalidGeometry)?;
self.objects
.add_prim_with_simulator_construction_data_uuid_vector3_vector3_quaternion(
simulator,
construction,
self.agent.active_group(),
target,
scale,
rotation,
)
.map_err(|_| BuildError::GridOperation)?;
receiver.await.map_err(|_| BuildError::GridOperation)
})
}
fn configure_prim(
&self,
receipt: &PrimReceipt,
prim: &BuildPrim,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()> {
let receipt = receipt.clone();
let prim = prim.clone();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let simulator = self.simulator()?;
self.objects
.set_name(simulator.clone(), receipt.local_id, prim.name.clone())
.map_err(|_| BuildError::GridOperation)?;
self.objects
.set_description(
simulator.clone(),
receipt.local_id,
prim.description.clone(),
)
.map_err(|_| BuildError::GridOperation)?;
let material = match prim.material.as_str() {
"wood" => libremetaverse_types::Material::Wood,
"metal" => libremetaverse_types::Material::Metal,
"glass" => libremetaverse_types::Material::Glass,
"stone" => libremetaverse_types::Material::Stone,
"plastic" => libremetaverse_types::Material::Plastic,
"rubber" => libremetaverse_types::Material::Rubber,
"flesh" => libremetaverse_types::Material::Flesh,
_ => return Err(BuildError::InvalidMaterial),
};
self.objects
.set_material(simulator.clone(), receipt.local_id, material)
.map_err(|_| BuildError::GridOperation)?;
let texture = prim
.texture_inventory_id
.as_ref()
.and_then(|id| UUID::new_with_string(id.clone()).ok())
.and_then(|id| lock(&self.resolved_textures).get(&id).copied())
.unwrap_or_else(libremetaverse::PrimitiveTextureEntry::white_texture);
let mut entry = libremetaverse::PrimitiveTextureEntry::new_with_uuid(texture)
.map_err(|_| BuildError::GridOperation)?;
if let Some(face) = entry.default_texture.as_mut() {
face.set_rgba(libremetaverse_types::Color4 {
r: prim.color_rgba[0] as f32,
g: prim.color_rgba[1] as f32,
b: prim.color_rgba[2] as f32,
a: prim.color_rgba[3] as f32,
});
}
self.objects
.set_textures_with_simulator_u_int32_texture_entry(
simulator.clone(),
receipt.local_id,
entry,
)
.map_err(|_| BuildError::GridOperation)?;
for who in [
libremetaverse::PermissionWho::EVERYONE,
libremetaverse::PermissionWho::GROUP,
libremetaverse::PermissionWho::NEXT_OWNER,
] {
self.objects
.set_permissions(
simulator.clone(),
vec![receipt.local_id],
who,
libremetaverse::PermissionMask::ALL,
false,
)
.map_err(|_| BuildError::GridOperation)?;
}
Ok(())
})
}
fn link_prims(
&self,
parent: &PrimReceipt,
child: &PrimReceipt,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()> {
let parent = parent.clone();
let child = child.clone();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
self.objects
.link_prims(self.simulator()?, vec![parent.local_id, child.local_id])
.map_err(|_| BuildError::GridOperation)
})
}
fn insert_script(
&self,
receipt: &PrimReceipt,
source: &str,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()> {
let receipt = receipt.clone();
let source = source.as_bytes().to_vec();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let item_id = UUID::random().map_err(|_| BuildError::GridOperation)?;
let mut item = libremetaverse::InventoryItem::new_with_inventory_type_uuid(
libremetaverse_types::InventoryType::LSL,
item_id,
)
.map_err(|_| BuildError::GridOperation)?;
item.set_description("Validated MetaCrate object script".into());
item.set_asset_type(libremetaverse_types::AssetType::Script);
item.set_permissions(libremetaverse::Permissions::full_permissions());
let task_id = self
.inventory
.update_task_inventory(receipt.local_id, item, Some(self.simulator()?), Some(false))
.map_err(|_| BuildError::GridOperation)?;
let result = self
.inventory
.request_update_script_task(
source,
item_id,
receipt.object_id,
true,
true,
Some(cancellation),
None,
)
.await
.map_err(|_| BuildError::GridOperation)?;
if !result.0 || task_id == UUID::zero() {
return Err(BuildError::GridOperation);
}
Ok(())
})
}
fn confirm_prim(
&self,
receipt: &PrimReceipt,
cancellation: CancellationToken,
) -> BuildFuture<'_, ()> {
let receipt = receipt.clone();
Box::pin(async move {
if cancellation.is_cancellation_requested() {
return Err(BuildError::Cancelled);
}
let primitive = self
.objects
.get_primitive(
self.simulator()?,
receipt.local_id,
receipt.object_id,
false,
)
.map_err(|_| BuildError::GridOperation)?;
if primitive.id != receipt.object_id {
return Err(BuildError::GridOperation);
}
Ok(())
})
}
fn delete_owned_prim(
&self,
receipt: &PrimReceipt,
_: CancellationToken,
) -> BuildFuture<'_, ()> {
let receipt = receipt.clone();
Box::pin(async move {
self.inventory
.request_de_rez_to_inventory_with_u_int32_de_rez_destination_uuid_uuid(
receipt.local_id,
libremetaverse::DeRezDestination::TrashFolder,
UUID::zero(),
UUID::random().map_err(|_| BuildError::GridOperation)?,
)
.map_err(|_| BuildError::GridOperation)
})
}
}