feat: stabilize OpenSim interactions and landmarks
This commit is contained in:
@@ -313,6 +313,14 @@ impl LibremetaverseClientOwner {
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl LibremetaverseWorldSnapshotSource {
|
||||
#[must_use]
|
||||
pub fn current_region_handle(&self) -> Option<u64> {
|
||||
self.client
|
||||
.network()
|
||||
.native_current_sim()
|
||||
.map(|simulator| simulator.handle)
|
||||
}
|
||||
|
||||
// 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)]
|
||||
|
||||
@@ -192,6 +192,7 @@ pub struct RuntimeView {
|
||||
pub transport_connected: bool,
|
||||
pub agent_ready: bool,
|
||||
pub region_id: Option<String>,
|
||||
pub region_handle: Option<u64>,
|
||||
pub region_name: Option<String>,
|
||||
pub position: Option<[f64; 3]>,
|
||||
pub behavior_mode: String,
|
||||
|
||||
@@ -46,6 +46,7 @@ impl ControlTarget for FakeTarget {
|
||||
transport_connected: true,
|
||||
agent_ready: true,
|
||||
region_id: Some("00000000-0000-4000-8000-000000000001".into()),
|
||||
region_handle: Some(9_007_199_254_740_992),
|
||||
region_name: Some("Safe Region".into()),
|
||||
position: Some([128.0, 128.0, 24.0]),
|
||||
behavior_mode: "available".into(),
|
||||
|
||||
@@ -59,6 +59,7 @@ struct RuntimeState {
|
||||
behavior: BehaviorMode,
|
||||
service_state: &'static str,
|
||||
region_id: Option<String>,
|
||||
region_handle: Option<u64>,
|
||||
region_name: Option<String>,
|
||||
position: Option<[f64; 3]>,
|
||||
}
|
||||
@@ -70,6 +71,7 @@ impl Default for RuntimeState {
|
||||
behavior: BehaviorMode::Offline,
|
||||
service_state: "starting",
|
||||
region_id: None,
|
||||
region_handle: None,
|
||||
region_name: None,
|
||||
position: None,
|
||||
}
|
||||
@@ -173,11 +175,13 @@ impl AgentControlTarget {
|
||||
pub fn update_region(
|
||||
&self,
|
||||
region_id: Option<String>,
|
||||
region_handle: Option<u64>,
|
||||
region_name: Option<String>,
|
||||
position: Option<[f64; 3]>,
|
||||
) {
|
||||
let mut state = lock(&self.state);
|
||||
state.region_id = region_id.filter(|value| value.len() <= 64);
|
||||
state.region_handle = region_handle.filter(|value| *value != 0);
|
||||
state.region_name = region_name.filter(|value| value.len() <= 256);
|
||||
state.position =
|
||||
position.filter(|value| value.iter().all(|component| component.is_finite()));
|
||||
@@ -259,6 +263,7 @@ impl AgentControlTarget {
|
||||
transport_connected: state.session.transport_connected,
|
||||
agent_ready: state.session.agent_ready,
|
||||
region_id: state.region_id,
|
||||
region_handle: state.region_handle,
|
||||
region_name: state.region_name,
|
||||
position: state.position,
|
||||
behavior_mode: behavior_name(state.behavior).to_owned(),
|
||||
|
||||
@@ -154,6 +154,7 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
||||
target.attach_vision_control(vision.clone());
|
||||
target.update_region(
|
||||
Some("00000000-0000-4000-8000-000000000001".into()),
|
||||
Some(9_007_199_254_740_992),
|
||||
Some("Test Region".into()),
|
||||
Some([128.0, 128.0, 24.0]),
|
||||
);
|
||||
@@ -172,6 +173,7 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
||||
panic!("runtime projection")
|
||||
};
|
||||
assert_eq!(runtime.region_name.as_deref(), Some("Test Region"));
|
||||
assert_eq!(runtime.region_handle, Some(9_007_199_254_740_992));
|
||||
assert_eq!(runtime.control_queue_capacity, 8);
|
||||
assert_eq!(runtime.active_visual_capture.as_deref(), Some("visual-1"));
|
||||
assert_eq!(runtime.visual_progress.as_deref(), Some("rendering"));
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
use crate::conversation::{ConversationChannel, ConversationKey, ConversationStore, MemoryRecord};
|
||||
use crate::llm::{CompletionMessage, ContentPart};
|
||||
use crate::types::{BoundedText, MAX_BODY_BYTES, MAX_IDENTIFIER_BYTES, MAX_MESSAGE_BYTES};
|
||||
use crate::types::{
|
||||
BoundedText, MAX_BODY_BYTES, MAX_IDENTIFIER_BYTES, MAX_MESSAGE_BYTES, MessageRole,
|
||||
};
|
||||
use libremetaverse_types::UUID;
|
||||
use libremetaverse_types::compat::{CancellationToken, CancellationTokenSource};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
@@ -27,6 +29,7 @@ const MAX_CONCURRENT_INFERENCE: usize = 64;
|
||||
const MAX_DUPLICATE_IDS: usize = 16_384;
|
||||
const MAX_DEBOUNCE_FRAGMENTS: usize = 16;
|
||||
const MAX_OBSERVATIONS: usize = 8_192;
|
||||
const CURRENT_TURN_SYSTEM_PROMPT: &str = "Handle only the newest avatar message. Earlier turns are already handled context. Never repeat an earlier mutation unless the newest message explicitly requests it. Do not mention unrelated or unavailable actions.";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub enum InteractionChannel {
|
||||
@@ -232,6 +235,7 @@ pub struct ResponseRequest {
|
||||
pub channel: InteractionChannel,
|
||||
pub origin: InteractionOrigin,
|
||||
pub intent: InteractionIntent,
|
||||
pub capabilities: BTreeSet<crate::policy::Capability>,
|
||||
pub messages: Vec<CompletionMessage>,
|
||||
}
|
||||
|
||||
@@ -1133,6 +1137,7 @@ async fn process_batch(
|
||||
channel: trigger.channel,
|
||||
origin,
|
||||
intent,
|
||||
capabilities: capabilities_for_intent(intent, &body),
|
||||
messages,
|
||||
};
|
||||
observe(
|
||||
@@ -1441,24 +1446,7 @@ fn classify_intent(
|
||||
|| normalized
|
||||
.split_whitespace()
|
||||
.any(|token| token.starts_with(['/', '!']))
|
||||
|| normalized
|
||||
.split(|character: char| !character.is_ascii_alphanumeric())
|
||||
.any(|token| {
|
||||
matches!(
|
||||
token,
|
||||
"teleport"
|
||||
| "move"
|
||||
| "rez"
|
||||
| "build"
|
||||
| "delete"
|
||||
| "give"
|
||||
| "upload"
|
||||
| "run"
|
||||
| "execute"
|
||||
| "wear"
|
||||
| "attach"
|
||||
)
|
||||
});
|
||||
|| command_capabilities(&normalized).len() > 1;
|
||||
match (channel, authorized, command) {
|
||||
(InteractionChannel::PublicChat, _, true) => InteractionIntent::PublicCommandDenied,
|
||||
(InteractionChannel::DirectIm, true, true) => InteractionIntent::PolicyGatedCommand,
|
||||
@@ -1466,6 +1454,57 @@ fn classify_intent(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capabilities_for_intent(
|
||||
intent: InteractionIntent,
|
||||
message: &str,
|
||||
) -> BTreeSet<crate::policy::Capability> {
|
||||
match intent {
|
||||
InteractionIntent::Informational => {
|
||||
BTreeSet::from([crate::policy::Capability::Informational])
|
||||
}
|
||||
InteractionIntent::PolicyGatedLslRequest => {
|
||||
BTreeSet::from([crate::policy::Capability::PublicLslRequest])
|
||||
}
|
||||
InteractionIntent::PolicyGatedCommand => {
|
||||
command_capabilities(&message.trim().to_ascii_lowercase())
|
||||
}
|
||||
InteractionIntent::PublicCommandDenied => BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_capabilities(message: &str) -> BTreeSet<crate::policy::Capability> {
|
||||
use crate::policy::Capability;
|
||||
|
||||
let mut capabilities = BTreeSet::from([Capability::Informational]);
|
||||
let tokens = message
|
||||
.split(|character: char| !character.is_ascii_alphanumeric())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if tokens.iter().any(|token| {
|
||||
matches!(
|
||||
*token,
|
||||
"teleport" | "move" | "walk" | "stop" | "sit" | "stand" | "face" | "look" | "schedule"
|
||||
)
|
||||
}) {
|
||||
capabilities.insert(Capability::Movement);
|
||||
}
|
||||
if tokens
|
||||
.iter()
|
||||
.any(|token| matches!(*token, "give" | "upload" | "wear" | "attach"))
|
||||
|| (tokens.contains("create") && tokens.contains("landmark"))
|
||||
{
|
||||
capabilities.insert(Capability::InventoryMutation);
|
||||
}
|
||||
if tokens.iter().any(|token| matches!(*token, "rez" | "build"))
|
||||
|| (tokens.contains("create") && !tokens.contains("landmark"))
|
||||
{
|
||||
capabilities.insert(Capability::Build);
|
||||
}
|
||||
if tokens.contains("delete") {
|
||||
capabilities.insert(Capability::ObjectMutation);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
fn safe_visible_response(text: &str, maximum: usize) -> Option<String> {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if text.trim().is_empty()
|
||||
@@ -1775,25 +1814,11 @@ impl InteractionResponder for PolicyLlmResponder {
|
||||
request.delivery_id.as_str(),
|
||||
)
|
||||
.map_err(|_| InteractionModelError::PolicyRejected)?;
|
||||
let capabilities = match request.intent {
|
||||
InteractionIntent::Informational => {
|
||||
BTreeSet::from([crate::policy::Capability::Informational])
|
||||
}
|
||||
InteractionIntent::PolicyGatedLslRequest => {
|
||||
BTreeSet::from([crate::policy::Capability::PublicLslRequest])
|
||||
}
|
||||
InteractionIntent::PolicyGatedCommand => BTreeSet::from([
|
||||
crate::policy::Capability::Informational,
|
||||
crate::policy::Capability::InventoryMutation,
|
||||
crate::policy::Capability::Movement,
|
||||
crate::policy::Capability::Build,
|
||||
crate::policy::Capability::ObjectMutation,
|
||||
]),
|
||||
InteractionIntent::PublicCommandDenied => BTreeSet::new(),
|
||||
};
|
||||
let tools =
|
||||
self.gateway
|
||||
.tools_for_capability_set(&context, (self.now)(), &capabilities);
|
||||
let tools = self.gateway.tools_for_capability_set(
|
||||
&context,
|
||||
(self.now)(),
|
||||
&request.capabilities,
|
||||
);
|
||||
let loop_owner = crate::tool_loop::SessionGeneration::default();
|
||||
let generation = loop_owner.current();
|
||||
let tool_loop = crate::tool_loop::ToolLoop::new(
|
||||
@@ -1810,14 +1835,14 @@ impl InteractionResponder for PolicyLlmResponder {
|
||||
Arc::clone(&self.now),
|
||||
)
|
||||
.map_err(|_| InteractionModelError::PolicyRejected)?;
|
||||
let mut messages = request.messages;
|
||||
messages.insert(
|
||||
0,
|
||||
CompletionMessage::text(MessageRole::System, CURRENT_TURN_SYSTEM_PROMPT)
|
||||
.map_err(|_| InteractionModelError::Failed)?,
|
||||
);
|
||||
let outcome = tool_loop
|
||||
.run(
|
||||
request.messages,
|
||||
&loop_owner,
|
||||
generation,
|
||||
&cancellation,
|
||||
&executor,
|
||||
)
|
||||
.run(messages, &loop_owner, generation, &cancellation, &executor)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
crate::tool_loop::ToolLoopError::Cancelled
|
||||
|
||||
@@ -430,7 +430,7 @@ async fn im_authorization_and_malicious_claims_cannot_change_origin() {
|
||||
"authorized",
|
||||
1,
|
||||
InteractionChannel::DirectIm,
|
||||
"/move north",
|
||||
"create a landmark here",
|
||||
))
|
||||
.await
|
||||
.expect("authorized IM");
|
||||
@@ -453,6 +453,13 @@ async fn im_authorization_and_malicious_claims_cannot_change_origin() {
|
||||
.expect("authorized request");
|
||||
assert_eq!(authorized.origin, InteractionOrigin::AuthorizedIm);
|
||||
assert_eq!(authorized.intent, InteractionIntent::PolicyGatedCommand);
|
||||
assert_eq!(
|
||||
authorized.capabilities,
|
||||
BTreeSet::from([
|
||||
crate::Capability::Informational,
|
||||
crate::Capability::InventoryMutation,
|
||||
])
|
||||
);
|
||||
let unprivileged = requests
|
||||
.iter()
|
||||
.find(|request| request.delivery_id.as_str() == "unprivileged")
|
||||
@@ -462,6 +469,21 @@ async fn im_authorization_and_malicious_claims_cannot_change_origin() {
|
||||
handle.shutdown().await.expect("shutdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_command_only_exposes_its_capability_family() {
|
||||
let teleport = capabilities_for_intent(
|
||||
InteractionIntent::PolicyGatedCommand,
|
||||
"Teleport using this landmark now",
|
||||
);
|
||||
assert_eq!(
|
||||
teleport,
|
||||
BTreeSet::from([
|
||||
crate::Capability::Informational,
|
||||
crate::Capability::Movement,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn debounce_preserves_fragment_order_and_expiry_creates_a_fresh_session() {
|
||||
let responder = Arc::new(FakeResponder::with_response("Combined."));
|
||||
|
||||
@@ -15,6 +15,7 @@ fn id(value: u128) -> UUID {
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeGrid {
|
||||
created: Mutex<Vec<String>>,
|
||||
accepted: Mutex<Vec<String>>,
|
||||
declined: Mutex<Vec<String>>,
|
||||
teleports: Mutex<Vec<UUID>>,
|
||||
@@ -23,6 +24,17 @@ struct FakeGrid {
|
||||
}
|
||||
|
||||
impl LandmarkGrid for FakeGrid {
|
||||
fn create_current_landmark(
|
||||
&self,
|
||||
name: String,
|
||||
_: CancellationToken,
|
||||
) -> LandmarkFuture<'_, OfferedInventoryNode> {
|
||||
Box::pin(async move {
|
||||
self.created.lock().expect("created").push(name.clone());
|
||||
Ok(landmark(980, 981, 70, &name))
|
||||
})
|
||||
}
|
||||
|
||||
fn accept_offer(&self, offer_id: &str, _: CancellationToken) -> LandmarkFuture<'_, ()> {
|
||||
let offer_id = offer_id.to_owned();
|
||||
Box::pin(async move {
|
||||
@@ -57,6 +69,33 @@ impl LandmarkGrid for FakeGrid {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_agent_creates_current_landmark_in_inventory_and_catalog() {
|
||||
let grid = Arc::new(FakeGrid::default());
|
||||
let service = service(grid.clone(), &[70]);
|
||||
let entry = service
|
||||
.create_current(
|
||||
id(70),
|
||||
"Current Test Location",
|
||||
CancellationToken::default(),
|
||||
)
|
||||
.await
|
||||
.expect("create current landmark");
|
||||
assert_eq!(entry.display_name, "Current Test Location");
|
||||
assert_eq!(service.entries(), vec![entry]);
|
||||
assert_eq!(
|
||||
grid.created.lock().expect("created").as_slice(),
|
||||
&["Current Test Location"]
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.create_current(id(71), "Denied", CancellationToken::default())
|
||||
.await,
|
||||
Err(LandmarkError::Unauthorized)
|
||||
);
|
||||
assert_eq!(grid.created.lock().expect("created").len(), 1);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FixedRandom;
|
||||
impl RoamingRandom for FixedRandom {
|
||||
@@ -513,6 +552,11 @@ fn public_and_unauthorized_im_cannot_select_teleport_or_change_roaming() {
|
||||
)
|
||||
.expect("gateway");
|
||||
for (origin, name, arguments) in [
|
||||
(
|
||||
ActionOrigin::public_chat(id(60)),
|
||||
LANDMARK_CREATE_TOOL,
|
||||
serde_json::json!({"name":"Denied"}),
|
||||
),
|
||||
(
|
||||
ActionOrigin::public_chat(id(60)),
|
||||
LANDMARK_TELEPORT_TOOL,
|
||||
@@ -555,3 +599,33 @@ fn public_and_unauthorized_im_cannot_select_teleport_or_change_roaming() {
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_configuration_does_not_spend_teleport_distance_budget() {
|
||||
let gateway = PolicyGateway::new(
|
||||
BTreeSet::from([id(62)]),
|
||||
landmark_policy_tools(LandmarkLimits::default()).expect("tools"),
|
||||
PolicyLimits::default(),
|
||||
Arc::new(MemoryPolicyAudit::new(16).expect("audit")),
|
||||
)
|
||||
.expect("gateway");
|
||||
let arguments = serde_json::json!({
|
||||
"schedule_id":"budget-safe",
|
||||
"minimum_interval_seconds":300,
|
||||
"maximum_interval_seconds":300,
|
||||
"enabled":false
|
||||
});
|
||||
let context =
|
||||
PolicyRequestContext::new(ActionOrigin::instant_message(id(62)), "session", "schedule")
|
||||
.expect("context");
|
||||
let call =
|
||||
ProposedToolCall::new("call", LANDMARK_SCHEDULE_TOOL, arguments.to_string()).expect("call");
|
||||
assert!(
|
||||
gateway
|
||||
.evaluate(&context, &call, &arguments, None, 1)
|
||||
.expect("decision")
|
||||
.into_authorization()
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(gateway.global_budget_usage().movement_millimeters, 0);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub const MAX_LANDMARK_NAME_BYTES: usize = 256;
|
||||
pub const LANDMARK_CREATE_TOOL: &str = "landmark_create_current";
|
||||
pub const LANDMARK_LIST_TOOL: &str = "landmark_catalog_list";
|
||||
pub const LANDMARK_TELEPORT_TOOL: &str = "landmark_teleport";
|
||||
pub const LANDMARK_SCHEDULE_TOOL: &str = "landmark_roaming_schedule";
|
||||
@@ -219,6 +220,7 @@ pub enum LandmarkError {
|
||||
Busy,
|
||||
Cooldown,
|
||||
Cancelled,
|
||||
CreateFailed,
|
||||
TeleportFailed,
|
||||
Persistence,
|
||||
CorruptCatalog,
|
||||
@@ -242,6 +244,7 @@ impl fmt::Display for LandmarkError {
|
||||
Self::Busy => "another teleport is active",
|
||||
Self::Cooldown => "teleport cooldown is active",
|
||||
Self::Cancelled => "landmark operation was cancelled",
|
||||
Self::CreateFailed => "current-location landmark creation failed",
|
||||
Self::TeleportFailed => "landmark teleport failed",
|
||||
Self::Persistence => "landmark catalog persistence failed",
|
||||
Self::CorruptCatalog => "landmark catalog is corrupt",
|
||||
@@ -257,6 +260,11 @@ pub type LandmarkFuture<'a, T> =
|
||||
Pin<Box<dyn Future<Output = Result<T, LandmarkError>> + Send + 'a>>;
|
||||
|
||||
pub trait LandmarkGrid: Send + Sync + 'static {
|
||||
fn create_current_landmark(
|
||||
&self,
|
||||
name: String,
|
||||
cancellation: CancellationToken,
|
||||
) -> LandmarkFuture<'_, OfferedInventoryNode>;
|
||||
fn accept_offer(
|
||||
&self,
|
||||
offer_id: &str,
|
||||
@@ -464,6 +472,38 @@ impl LandmarkService {
|
||||
lock(&self.observations).iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn create_current(
|
||||
&self,
|
||||
requesting_avatar: UUID,
|
||||
name: &str,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<LandmarkEntry, LandmarkError> {
|
||||
if !self.authorized.contains(&requesting_avatar) {
|
||||
return Err(LandmarkError::Unauthorized);
|
||||
}
|
||||
validate_landmark_name(name)?;
|
||||
if cancellation.is_cancellation_requested() {
|
||||
return Err(LandmarkError::Cancelled);
|
||||
}
|
||||
if lock(&self.state).entries.len() >= self.limits.max_catalog_entries {
|
||||
return Err(LandmarkError::CatalogFull);
|
||||
}
|
||||
let node = self
|
||||
.grid
|
||||
.create_current_landmark(name.to_owned(), cancellation)
|
||||
.await?;
|
||||
let offer = LandmarkOffer {
|
||||
offer_id: "created-current-location".to_owned(),
|
||||
sender_id: node.owner_id,
|
||||
root: node,
|
||||
received_unix_millis: unix_millis(),
|
||||
};
|
||||
let mut entries = flatten_offer(&offer, self.limits)?;
|
||||
let entry = entries.pop().ok_or(LandmarkError::CreateFailed)?;
|
||||
self.commit_entries(vec![entry.clone()])?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_authorized(&self, avatar_id: UUID) -> bool {
|
||||
self.authorized.contains(&avatar_id)
|
||||
@@ -491,16 +531,20 @@ impl LandmarkService {
|
||||
if token.is_cancellation_requested() {
|
||||
break;
|
||||
}
|
||||
let now = unix_millis();
|
||||
if pause.active()
|
||||
|| !schedule.enabled
|
||||
|| schedule.remaining_runs == 0
|
||||
|| now < schedule.next_run_unix_millis
|
||||
|| now >= schedule.expires_unix_millis
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(behavior) = &behavior {
|
||||
let _ = behavior.set_roaming(true);
|
||||
}
|
||||
let _ = service
|
||||
.execute_due_roaming(
|
||||
&schedule.schedule_id,
|
||||
unix_millis(),
|
||||
pause,
|
||||
token.clone(),
|
||||
)
|
||||
.execute_due_roaming(&schedule.schedule_id, now, pause, token.clone())
|
||||
.await;
|
||||
if let Some(behavior) = &behavior {
|
||||
let _ = behavior.set_roaming(false);
|
||||
@@ -939,12 +983,7 @@ fn flatten_offer(
|
||||
{
|
||||
return Err(LandmarkError::InvalidOffer);
|
||||
}
|
||||
if node.name.is_empty()
|
||||
|| node.name.len() > MAX_LANDMARK_NAME_BYTES
|
||||
|| node.name.chars().any(char::is_control)
|
||||
{
|
||||
return Err(LandmarkError::InvalidOffer);
|
||||
}
|
||||
validate_landmark_name(&node.name)?;
|
||||
match node.kind {
|
||||
OfferedInventoryKind::Landmark {
|
||||
asset_id,
|
||||
@@ -986,6 +1025,17 @@ fn flatten_offer(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_landmark_name(value: &str) -> Result<(), LandmarkError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_LANDMARK_NAME_BYTES
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
Err(LandmarkError::InvalidOffer)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_offer_id(value: &str) -> Result<(), LandmarkError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
@@ -1042,6 +1092,12 @@ struct SelectorArguments {
|
||||
selector: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateArguments {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ScheduleArguments {
|
||||
@@ -1087,6 +1143,22 @@ impl AuthorizedToolBackend for LandmarkToolBackend {
|
||||
let principal = action.authenticated_avatar_id();
|
||||
let result: Result<String, LandmarkError> = async {
|
||||
match action.call().name.as_str() {
|
||||
LANDMARK_CREATE_TOOL => {
|
||||
let principal = principal.ok_or(LandmarkError::Unauthorized)?;
|
||||
let arguments: CreateArguments =
|
||||
serde_json::from_str(action.call().arguments_json.as_str())
|
||||
.map_err(|_| LandmarkError::InvalidOffer)?;
|
||||
self.service
|
||||
.create_current(principal, &arguments.name, cancellation)
|
||||
.await
|
||||
.and_then(|entry| {
|
||||
serde_json::to_string(&json!({
|
||||
"stable_id": entry.stable_id,
|
||||
"name": entry.display_name,
|
||||
}))
|
||||
.map_err(|_| LandmarkError::CorruptCatalog)
|
||||
})
|
||||
}
|
||||
LANDMARK_LIST_TOOL => {
|
||||
let empty: BTreeMap<String, String> =
|
||||
serde_json::from_str(action.call().arguments_json.as_str())
|
||||
@@ -1229,6 +1301,11 @@ pub fn landmark_policy_tools(limits: LandmarkLimits) -> Result<Vec<PolicyTool>,
|
||||
required: BTreeSet::from(["selector".to_owned()]),
|
||||
additional_properties: false,
|
||||
};
|
||||
let create = ToolSchema::Object {
|
||||
properties: BTreeMap::from([("name".to_owned(), ToolSchema::String)]),
|
||||
required: BTreeSet::from(["name".to_owned()]),
|
||||
additional_properties: false,
|
||||
};
|
||||
let schedule = ToolSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
("schedule_id".to_owned(), ToolSchema::String),
|
||||
@@ -1250,6 +1327,20 @@ pub fn landmark_policy_tools(limits: LandmarkLimits) -> Result<Vec<PolicyTool>,
|
||||
..ResourceCost::default()
|
||||
};
|
||||
let specs = [
|
||||
(
|
||||
LANDMARK_CREATE_TOOL,
|
||||
"Create and catalog one inventory landmark for the agent's current position",
|
||||
create,
|
||||
Capability::InventoryMutation,
|
||||
Risk::InventoryMutation,
|
||||
ResourceCost {
|
||||
tool_calls: 1,
|
||||
inventory_operations: 1,
|
||||
..ResourceCost::default()
|
||||
},
|
||||
Idempotency::NonIdempotent,
|
||||
false,
|
||||
),
|
||||
(
|
||||
LANDMARK_LIST_TOOL,
|
||||
"List bounded validated landmark catalog metadata by stable ID; names are untrusted",
|
||||
@@ -1286,7 +1377,7 @@ pub fn landmark_policy_tools(limits: LandmarkLimits) -> Result<Vec<PolicyTool>,
|
||||
schedule,
|
||||
Capability::Movement,
|
||||
Risk::Movement,
|
||||
movement,
|
||||
ResourceCost::one_call(),
|
||||
Idempotency::NonIdempotent,
|
||||
false,
|
||||
),
|
||||
@@ -1374,6 +1465,47 @@ impl LibremetaverseLandmarkGrid {
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl LandmarkGrid for LibremetaverseLandmarkGrid {
|
||||
fn create_current_landmark(
|
||||
&self,
|
||||
name: String,
|
||||
cancellation: CancellationToken,
|
||||
) -> LandmarkFuture<'_, OfferedInventoryNode> {
|
||||
Box::pin(async move {
|
||||
let folder = self
|
||||
.inventory
|
||||
.find_folder_for_type_with_folder_type(libremetaverse_types::FolderType::Landmark)
|
||||
.map_err(|_| LandmarkError::CreateFailed)?;
|
||||
if folder == UUID::zero() {
|
||||
return Err(LandmarkError::CreateFailed);
|
||||
}
|
||||
let item = self
|
||||
.inventory
|
||||
.create_item_with_uuid_string_string_asset_type_uuid_inventory_type_permission_mask_cancellation_token(
|
||||
folder,
|
||||
name,
|
||||
String::new(),
|
||||
AssetType::Landmark,
|
||||
UUID::zero(),
|
||||
libremetaverse_types::InventoryType::LANDMARK,
|
||||
libremetaverse::PermissionMask::ALL,
|
||||
Some(cancellation.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| LandmarkError::CreateFailed)?
|
||||
.ok_or(LandmarkError::CreateFailed)?;
|
||||
Ok(OfferedInventoryNode {
|
||||
inventory_id: item.base.uuid(),
|
||||
owner_id: item.base.owner_id(),
|
||||
name: item.base.name(),
|
||||
kind: OfferedInventoryKind::Landmark {
|
||||
asset_id: item.asset_uuid(),
|
||||
permissions_fingerprint: permission_fingerprint(item.permissions()),
|
||||
},
|
||||
children: Vec::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn accept_offer(
|
||||
&self,
|
||||
offer_id: &str,
|
||||
|
||||
@@ -115,9 +115,9 @@ pub use interaction::{
|
||||
VisibleResponse, split_utf8,
|
||||
};
|
||||
pub use landmarks::{
|
||||
LANDMARK_LIST_TOOL, LANDMARK_SCHEDULE_TOOL, LANDMARK_STATUS_TOOL, LANDMARK_TELEPORT_TOOL,
|
||||
LandmarkEntry, LandmarkError, LandmarkFuture, LandmarkGrid, LandmarkLimits,
|
||||
LandmarkObservation, LandmarkObservationKind, LandmarkOffer, LandmarkOutcome,
|
||||
LANDMARK_CREATE_TOOL, LANDMARK_LIST_TOOL, LANDMARK_SCHEDULE_TOOL, LANDMARK_STATUS_TOOL,
|
||||
LANDMARK_TELEPORT_TOOL, LandmarkEntry, LandmarkError, LandmarkFuture, LandmarkGrid,
|
||||
LandmarkLimits, LandmarkObservation, LandmarkObservationKind, LandmarkOffer, LandmarkOutcome,
|
||||
LandmarkRoamingHandle, LandmarkService, LandmarkToolBackend, LandmarkValidation, OfferDecision,
|
||||
OfferedInventoryKind, OfferedInventoryNode, RoamingPause, RoamingRandom, RoamingSchedule,
|
||||
SystemRoamingRandom, TeleportReceipt, TeleportTrigger, landmark_policy_tools,
|
||||
|
||||
@@ -275,7 +275,7 @@ async fn run_live(
|
||||
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
|
||||
GridSessionBackend, LibremetaverseClientOwner, OperatingMode, RuntimeControlCommand,
|
||||
SessionControl, SessionObservation, SessionState, SessionSupervisor, TcpControlConfig,
|
||||
TcpControlServer,
|
||||
TcpControlServer, WorldSnapshotSource,
|
||||
};
|
||||
|
||||
let connection = config.grid.clone().ok_or_else(|| {
|
||||
@@ -449,6 +449,24 @@ async fn run_live(
|
||||
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
||||
live.vision.set_generation(status.generation);
|
||||
control_target.update_session(status);
|
||||
if status.agent_ready
|
||||
&& let Ok(snapshot) = live
|
||||
.world
|
||||
.capture(
|
||||
status.generation,
|
||||
libremetaverse_types::compat::CancellationToken::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
control_target.update_region(
|
||||
Some(snapshot.region_id.to_string()),
|
||||
live.world.current_region_handle(),
|
||||
Some(snapshot.region_name),
|
||||
snapshot.agent.position.map(|position| {
|
||||
[position.x, position.y, position.z]
|
||||
}),
|
||||
);
|
||||
}
|
||||
live.landmark_roaming
|
||||
.update_pause(|pause| pause.degraded = !status.agent_ready);
|
||||
control_plane.publish(ControlEventKind::StateChanged {
|
||||
@@ -582,6 +600,7 @@ struct LiveInteractions {
|
||||
build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
|
||||
vision:
|
||||
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
|
||||
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -612,8 +631,9 @@ fn start_live_interactions(
|
||||
..LlmTransportLimits::default()
|
||||
};
|
||||
let conversation = Arc::new(ConversationStore::from_config(config)?);
|
||||
let world = Arc::new(owner.world_snapshot_source());
|
||||
let perception = Arc::new(PerceptionBackend::new(
|
||||
Arc::new(owner.world_snapshot_source()),
|
||||
world.clone(),
|
||||
Arc::clone(&conversation),
|
||||
config.limits.observable_queue,
|
||||
)?);
|
||||
@@ -760,6 +780,7 @@ fn start_live_interactions(
|
||||
landmark_roaming,
|
||||
build_control,
|
||||
vision,
|
||||
world,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ use crate::conversation::ConversationClock;
|
||||
use crate::interaction::{
|
||||
DeliveryFuture, InteractionDeliveryError, InteractionSink, OutboundInteraction,
|
||||
};
|
||||
use crate::landmarks::{LandmarkError, LandmarkFuture, LandmarkGrid, RoamingRandom};
|
||||
use crate::landmarks::{
|
||||
LandmarkError, LandmarkFuture, LandmarkGrid, OfferedInventoryKind, OfferedInventoryNode,
|
||||
RoamingRandom,
|
||||
};
|
||||
use crate::perception::{SnapshotFuture, WorldPosition, WorldSnapshot, WorldSnapshotSource};
|
||||
use crate::script_delivery::{
|
||||
GeneratedScript, ScriptDeliveryError, ScriptInventory, ScriptInventoryFuture,
|
||||
@@ -849,6 +852,30 @@ fn build_unit(
|
||||
}
|
||||
|
||||
impl LandmarkGrid for Arc<FakeGrid> {
|
||||
fn create_current_landmark(
|
||||
&self,
|
||||
name: String,
|
||||
_: CancellationToken,
|
||||
) -> LandmarkFuture<'_, OfferedInventoryNode> {
|
||||
let grid = self.clone();
|
||||
Box::pin(async move {
|
||||
grid.record("landmark", "landmark.create", None, name.as_bytes(), "ok");
|
||||
Ok(OfferedInventoryNode {
|
||||
inventory_id: UUID::new_with_string("00000000-0000-0000-0000-000000000980".into())
|
||||
.map_err(|_| LandmarkError::CreateFailed)?,
|
||||
owner_id: UUID::new_with_string("00000000-0000-0000-0000-000000000070".into())
|
||||
.map_err(|_| LandmarkError::CreateFailed)?,
|
||||
name,
|
||||
kind: OfferedInventoryKind::Landmark {
|
||||
asset_id: UUID::new_with_string("00000000-0000-0000-0000-000000000981".into())
|
||||
.map_err(|_| LandmarkError::CreateFailed)?,
|
||||
permissions_fingerprint: 0,
|
||||
},
|
||||
children: Vec::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn accept_offer(&self, offer_id: &str, _: CancellationToken) -> LandmarkFuture<'_, ()> {
|
||||
landmark_unit(self.clone(), "landmark.accept", offer_id, &[])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ impl TuiTransport for FakeTransport {
|
||||
transport_connected: true,
|
||||
agent_ready: true,
|
||||
region_id: Some("region".into()),
|
||||
region_handle: Some(9_007_199_254_740_992),
|
||||
region_name: Some("Café 世界".into()),
|
||||
position: Some([1.0, 2.0, 3.0]),
|
||||
behavior_mode: "active".into(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::vision::*;
|
||||
use crate::{
|
||||
BoundedText, CompletionMessage, InteractionChannel, InteractionIntent, InteractionModelError,
|
||||
InteractionOrigin, InteractionResponder, MessageRole, ResponderFuture, ResponseRequest,
|
||||
VisibleResponse,
|
||||
BoundedText, Capability, CompletionMessage, InteractionChannel, InteractionIntent,
|
||||
InteractionModelError, InteractionOrigin, InteractionResponder, MessageRole, ResponderFuture,
|
||||
ResponseRequest, VisibleResponse,
|
||||
};
|
||||
use libremetaverse_types::{
|
||||
UUID,
|
||||
@@ -302,6 +302,7 @@ fn request(text: &str) -> ResponseRequest {
|
||||
channel: InteractionChannel::DirectIm,
|
||||
origin: InteractionOrigin::AuthorizedIm,
|
||||
intent: InteractionIntent::Informational,
|
||||
capabilities: std::collections::BTreeSet::from([Capability::Informational]),
|
||||
messages: vec![CompletionMessage::text(MessageRole::Avatar, text).unwrap()],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user