feat: complete stable OpenSim live interactions
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user