1710 lines
58 KiB
Rust
1710 lines
58 KiB
Rust
//! Generation-fenced embodied attention and bounded presence behavior.
|
|
|
|
#![allow(clippy::missing_errors_doc)]
|
|
|
|
use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture};
|
|
use crate::config::BehaviorSettings;
|
|
use crate::interaction::{PacerFuture, ResponsePacer};
|
|
use crate::llm::{ToolDefinition, ToolSchema};
|
|
use crate::perception::WorldPosition;
|
|
use crate::policy::{
|
|
AllowedOrigins, ApprovalRule, AuthorizedAction, Capability, FixedCost, Idempotency,
|
|
OriginClass, PolicyError, PolicyReasonCode, PolicyTool, ResourceCost, ResourceEstimator, Risk,
|
|
};
|
|
use crate::types::{
|
|
BoundedText, MAX_BODY_BYTES, MAX_IDENTIFIER_BYTES, MAX_OBSERVABLE_DETAIL_BYTES, ToolCallOutcome,
|
|
};
|
|
use libremetaverse_types::UUID;
|
|
use libremetaverse_types::compat::CancellationToken;
|
|
use serde::Serialize;
|
|
use serde_json::{Map, Value, json};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::error::Error;
|
|
use std::fmt;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use tokio::sync::{mpsc, oneshot, watch};
|
|
use tokio::task::JoinHandle;
|
|
|
|
pub const FACE_AVATAR_TOOL: &str = "behavior_face_avatar";
|
|
pub const FACE_POINT_TOOL: &str = "behavior_face_point";
|
|
pub const LOOK_AROUND_TOOL: &str = "behavior_look_around";
|
|
pub const WALK_SHORT_TOOL: &str = "behavior_walk_short";
|
|
pub const STOP_TOOL: &str = "behavior_stop";
|
|
pub const SIT_TOOL: &str = "behavior_sit";
|
|
pub const STAND_TOOL: &str = "behavior_stand";
|
|
pub const CURRENT_POSE_TOOL: &str = "behavior_current_pose";
|
|
pub const CAMERA_SET_TOOL: &str = "behavior_camera_set";
|
|
pub const CAMERA_RESET_TOOL: &str = "behavior_camera_reset";
|
|
|
|
const WALK_POLL: Duration = Duration::from_millis(250);
|
|
const TURN_INTERVAL: Duration = Duration::from_millis(40);
|
|
const MAX_ACTION_ID_BYTES: usize = MAX_IDENTIFIER_BYTES;
|
|
const MAX_TURN_STEP_DEGREES: f64 = 30.0;
|
|
const ARRIVAL_METERS: f64 = 0.5;
|
|
const STUCK_PROGRESS_METERS: f64 = 0.05;
|
|
const MAX_TOOL_RESULT_BYTES: usize = 8 * 1024;
|
|
|
|
pub type EmbodimentFuture<'a, T> = BackendFuture<'a, Result<T, BehaviorError>>;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BehaviorMode {
|
|
Offline,
|
|
Settling,
|
|
Available,
|
|
Engaged,
|
|
Executing,
|
|
Roaming,
|
|
Paused,
|
|
Recovering,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BehaviorOutcome {
|
|
Completed,
|
|
Cancelled,
|
|
TimedOut,
|
|
Stuck,
|
|
Rejected,
|
|
Preempted,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct EmbodiedPose {
|
|
pub generation: u64,
|
|
pub region_id: UUID,
|
|
pub position: WorldPosition,
|
|
pub heading_degrees: f64,
|
|
pub sitting: bool,
|
|
}
|
|
|
|
impl EmbodiedPose {
|
|
fn valid(&self, generation: u64, region_id: UUID) -> bool {
|
|
self.generation == generation
|
|
&& self.region_id == region_id
|
|
&& self.position.x.is_finite()
|
|
&& self.position.y.is_finite()
|
|
&& self.position.z.is_finite()
|
|
&& self.heading_degrees.is_finite()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum BehaviorError {
|
|
NotReady,
|
|
Paused,
|
|
EmergencyStopped,
|
|
InvalidArguments,
|
|
TargetUnavailable,
|
|
RegionBoundary,
|
|
RateLimited,
|
|
Cancelled,
|
|
TimedOut,
|
|
Stuck,
|
|
NativeOperation,
|
|
QueueClosed,
|
|
}
|
|
|
|
impl fmt::Display for BehaviorError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(match self {
|
|
Self::NotReady => "embodied behavior is unavailable before session readiness",
|
|
Self::Paused => "embodied behavior is paused by the operator",
|
|
Self::EmergencyStopped => "embodied behavior emergency stop is active",
|
|
Self::InvalidArguments => "behavior tool arguments are invalid or outside bounds",
|
|
Self::TargetUnavailable => "the requested attention target is unavailable",
|
|
Self::RegionBoundary => "the requested action would leave the current region",
|
|
Self::RateLimited => "embodied action rate limit is active",
|
|
Self::Cancelled => "embodied action was cancelled",
|
|
Self::TimedOut => "embodied action timed out",
|
|
Self::Stuck => "bounded walk stopped after making no progress",
|
|
Self::NativeOperation => "native movement operation failed",
|
|
Self::QueueClosed => "embodied behavior controller is unavailable",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Error for BehaviorError {}
|
|
|
|
/// High-level native boundary. No control flags, raw packets, teleport, flight,
|
|
/// animation, touch, following, or unrestricted autopilot are exposed here.
|
|
pub trait EmbodimentSink: Send + Sync + 'static {
|
|
fn current_pose(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, EmbodiedPose>;
|
|
fn resolve_avatar(
|
|
&self,
|
|
generation: u64,
|
|
avatar_id: UUID,
|
|
cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, WorldPosition>;
|
|
fn face_point(
|
|
&self,
|
|
generation: u64,
|
|
point: WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, ()>;
|
|
fn begin_walk(
|
|
&self,
|
|
generation: u64,
|
|
point: WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, ()>;
|
|
fn validate_walk_target(
|
|
&self,
|
|
generation: u64,
|
|
point: WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, ()>;
|
|
fn stop(&self, generation: u64, cancellation: CancellationToken) -> EmbodimentFuture<'_, ()>;
|
|
fn sit(&self, generation: u64, cancellation: CancellationToken) -> EmbodimentFuture<'_, ()>;
|
|
fn stand(&self, generation: u64, cancellation: CancellationToken) -> EmbodimentFuture<'_, ()>;
|
|
fn set_camera(
|
|
&self,
|
|
_generation: u64,
|
|
_position: WorldPosition,
|
|
_target: WorldPosition,
|
|
_vertical_fov_degrees: f64,
|
|
_cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, ()> {
|
|
Box::pin(async { Err(BehaviorError::TargetUnavailable) })
|
|
}
|
|
fn reset_camera(
|
|
&self,
|
|
_generation: u64,
|
|
_cancellation: CancellationToken,
|
|
) -> EmbodimentFuture<'_, ()> {
|
|
Box::pin(async { Err(BehaviorError::TargetUnavailable) })
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum BehaviorTrigger {
|
|
SessionReady,
|
|
SessionDisconnected,
|
|
RegionChanged,
|
|
PublicResponse {
|
|
delivery_id: String,
|
|
avatar_id: UUID,
|
|
},
|
|
AuthorizedTool {
|
|
authorization_id: u64,
|
|
tool: String,
|
|
},
|
|
IdleTimer,
|
|
OperatorPause,
|
|
OperatorResume,
|
|
EmergencyStop,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum BehaviorPolicyResult {
|
|
BuiltInAttention,
|
|
Authorized(u64),
|
|
InternalIdle,
|
|
OperatorOverride,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum BehaviorObservation {
|
|
Transition {
|
|
from: BehaviorMode,
|
|
to: BehaviorMode,
|
|
trigger: BehaviorTrigger,
|
|
},
|
|
Action {
|
|
action_id: String,
|
|
generation: Option<u64>,
|
|
region_id: Option<UUID>,
|
|
action: String,
|
|
trigger: BehaviorTrigger,
|
|
policy: BehaviorPolicyResult,
|
|
duration_millis: u64,
|
|
outcome: BehaviorOutcome,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct ReadyState {
|
|
generation: u64,
|
|
region_id: UUID,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum BehaviorAction {
|
|
FaceAvatar(UUID),
|
|
FacePoint(WorldPosition),
|
|
LookAround,
|
|
WalkShort {
|
|
heading_degrees: f64,
|
|
distance_meters: f64,
|
|
},
|
|
Stop,
|
|
Sit,
|
|
Stand,
|
|
CurrentPose,
|
|
CameraSet {
|
|
position: WorldPosition,
|
|
target: WorldPosition,
|
|
vertical_fov_degrees: f64,
|
|
},
|
|
CameraReset,
|
|
}
|
|
|
|
impl BehaviorAction {
|
|
fn name(&self) -> &'static str {
|
|
match self {
|
|
Self::FaceAvatar(_) => FACE_AVATAR_TOOL,
|
|
Self::FacePoint(_) => FACE_POINT_TOOL,
|
|
Self::LookAround => LOOK_AROUND_TOOL,
|
|
Self::WalkShort { .. } => WALK_SHORT_TOOL,
|
|
Self::Stop => STOP_TOOL,
|
|
Self::Sit => SIT_TOOL,
|
|
Self::Stand => STAND_TOOL,
|
|
Self::CurrentPose => CURRENT_POSE_TOOL,
|
|
Self::CameraSet { .. } => CAMERA_SET_TOOL,
|
|
Self::CameraReset => CAMERA_RESET_TOOL,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ActionRequest {
|
|
action_id: String,
|
|
action: BehaviorAction,
|
|
trigger: BehaviorTrigger,
|
|
policy: BehaviorPolicyResult,
|
|
reply: oneshot::Sender<Result<Value, BehaviorError>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ActionRegistry {
|
|
active: BTreeSet<String>,
|
|
cancelled: BTreeSet<String>,
|
|
}
|
|
|
|
enum Command {
|
|
Action(ActionRequest),
|
|
Attention {
|
|
action_id: String,
|
|
delivery_id: String,
|
|
avatar_id: UUID,
|
|
reply: oneshot::Sender<Result<(), BehaviorError>>,
|
|
},
|
|
Roaming(bool),
|
|
Shutdown(oneshot::Sender<()>),
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct BehaviorIngress {
|
|
commands: mpsc::Sender<Command>,
|
|
ready: watch::Sender<Option<ReadyState>>,
|
|
paused: watch::Sender<bool>,
|
|
emergency: watch::Sender<bool>,
|
|
action_cancel: watch::Sender<u64>,
|
|
actions: Arc<Mutex<ActionRegistry>>,
|
|
next_action: Arc<AtomicU64>,
|
|
action_capacity: usize,
|
|
}
|
|
|
|
impl fmt::Debug for BehaviorIngress {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("BehaviorIngress")
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl BehaviorIngress {
|
|
pub fn connected(&self, generation: u64, region_id: UUID) -> Result<(), BehaviorError> {
|
|
if generation == 0 || region_id == UUID::zero() {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
self.ready
|
|
.send(Some(ReadyState {
|
|
generation,
|
|
region_id,
|
|
}))
|
|
.map_err(|_| BehaviorError::QueueClosed)
|
|
}
|
|
|
|
pub fn region_changed(&self, generation: u64, region_id: UUID) -> Result<(), BehaviorError> {
|
|
self.connected(generation, region_id)
|
|
}
|
|
|
|
pub fn disconnected(&self) {
|
|
let _ = self.ready.send(None);
|
|
}
|
|
|
|
pub fn pause(&self) {
|
|
let _ = self.paused.send(true);
|
|
}
|
|
|
|
pub fn resume(&self) {
|
|
let _ = self.paused.send(false);
|
|
}
|
|
|
|
pub fn emergency_stop(&self) {
|
|
let _ = self.emergency.send(true);
|
|
}
|
|
|
|
pub fn clear_emergency_stop(&self) {
|
|
let _ = self.emergency.send(false);
|
|
}
|
|
|
|
/// Cancels one exact queued or executing behavior action ID.
|
|
pub fn cancel_action(&self, action_id: &str) -> Result<bool, BehaviorError> {
|
|
if !valid_action_id(action_id) {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
let cancelled = {
|
|
let mut actions = lock_actions(&self.actions);
|
|
if actions.active.contains(action_id) {
|
|
actions.cancelled.insert(action_id.to_owned());
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if cancelled {
|
|
self.action_cancel.send_modify(|generation| {
|
|
*generation = generation.saturating_add(1);
|
|
});
|
|
}
|
|
Ok(cancelled)
|
|
}
|
|
|
|
/// Marks a separately authorized scheduler task as roaming. The controller
|
|
/// itself never invents a roaming route or exposes follow/wander behavior.
|
|
pub fn set_roaming(&self, roaming: bool) -> Result<(), BehaviorError> {
|
|
self.commands
|
|
.try_send(Command::Roaming(roaming))
|
|
.map_err(|_| BehaviorError::QueueClosed)
|
|
}
|
|
|
|
async fn request(
|
|
&self,
|
|
action_id: String,
|
|
action: BehaviorAction,
|
|
trigger: BehaviorTrigger,
|
|
policy: BehaviorPolicyResult,
|
|
) -> Result<Value, BehaviorError> {
|
|
if !self.register_action(&action_id) {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
let (reply, receive) = oneshot::channel();
|
|
if self
|
|
.commands
|
|
.send(Command::Action(ActionRequest {
|
|
action_id: action_id.clone(),
|
|
action,
|
|
trigger,
|
|
policy,
|
|
reply,
|
|
}))
|
|
.await
|
|
.is_err()
|
|
{
|
|
self.unregister_action(&action_id);
|
|
return Err(BehaviorError::QueueClosed);
|
|
}
|
|
receive.await.map_err(|_| BehaviorError::QueueClosed)?
|
|
}
|
|
|
|
pub async fn attention(
|
|
&self,
|
|
delivery_id: String,
|
|
avatar_id: UUID,
|
|
) -> Result<(), BehaviorError> {
|
|
let action_id = format!(
|
|
"attention-{}",
|
|
self.next_action.fetch_add(1, Ordering::Relaxed)
|
|
);
|
|
if !self.register_action(&action_id) {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
let (reply, receive) = oneshot::channel();
|
|
if self
|
|
.commands
|
|
.send(Command::Attention {
|
|
action_id: action_id.clone(),
|
|
delivery_id,
|
|
avatar_id,
|
|
reply,
|
|
})
|
|
.await
|
|
.is_err()
|
|
{
|
|
self.unregister_action(&action_id);
|
|
return Err(BehaviorError::QueueClosed);
|
|
}
|
|
receive.await.map_err(|_| BehaviorError::QueueClosed)?
|
|
}
|
|
|
|
fn register_action(&self, action_id: &str) -> bool {
|
|
if !valid_action_id(action_id) {
|
|
return false;
|
|
}
|
|
let mut actions = lock_actions(&self.actions);
|
|
actions.active.len() < self.action_capacity && actions.active.insert(action_id.to_owned())
|
|
}
|
|
|
|
fn unregister_action(&self, action_id: &str) {
|
|
let mut actions = lock_actions(&self.actions);
|
|
actions.active.remove(action_id);
|
|
actions.cancelled.remove(action_id);
|
|
}
|
|
}
|
|
|
|
impl ResponsePacer for BehaviorIngress {
|
|
fn prepare_public_response(
|
|
&self,
|
|
delivery_id: String,
|
|
avatar_id: UUID,
|
|
cancellation: CancellationToken,
|
|
) -> PacerFuture<'_> {
|
|
Box::pin(async move {
|
|
tokio::select! {
|
|
() = cancellation.cancelled() => Err(crate::interaction::InteractionPacingError::Cancelled),
|
|
result = self.attention(delivery_id, avatar_id) => result.map_err(|_| crate::interaction::InteractionPacingError::Unavailable),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct BehaviorHandle {
|
|
ingress: BehaviorIngress,
|
|
observations: mpsc::Receiver<BehaviorObservation>,
|
|
task: Option<JoinHandle<()>>,
|
|
shutdown_timeout: Duration,
|
|
}
|
|
|
|
impl BehaviorHandle {
|
|
#[must_use]
|
|
pub fn ingress(&self) -> BehaviorIngress {
|
|
self.ingress.clone()
|
|
}
|
|
|
|
pub async fn next_observation(&mut self) -> Option<BehaviorObservation> {
|
|
self.observations.recv().await
|
|
}
|
|
|
|
pub async fn shutdown(mut self) -> Result<(), BehaviorError> {
|
|
let (reply, mut receive) = oneshot::channel();
|
|
self.ingress
|
|
.commands
|
|
.send(Command::Shutdown(reply))
|
|
.await
|
|
.map_err(|_| BehaviorError::QueueClosed)?;
|
|
tokio::time::timeout(self.shutdown_timeout, async {
|
|
loop {
|
|
tokio::select! {
|
|
result = &mut receive => return result.map_err(|_| BehaviorError::QueueClosed),
|
|
observation = self.observations.recv() => {
|
|
if observation.is_none() {
|
|
return receive.await.map_err(|_| BehaviorError::QueueClosed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| BehaviorError::TimedOut)??;
|
|
if let Some(task) = self.task.take() {
|
|
tokio::time::timeout(self.shutdown_timeout, task)
|
|
.await
|
|
.map_err(|_| BehaviorError::TimedOut)?
|
|
.map_err(|_| BehaviorError::QueueClosed)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct BehaviorController {
|
|
settings: BehaviorSettings,
|
|
sink: Arc<dyn EmbodimentSink>,
|
|
queue_capacity: usize,
|
|
observation_capacity: usize,
|
|
shutdown_timeout: Duration,
|
|
random: Arc<dyn BehaviorRandom>,
|
|
}
|
|
|
|
impl BehaviorController {
|
|
pub fn new(
|
|
settings: BehaviorSettings,
|
|
sink: Arc<dyn EmbodimentSink>,
|
|
queue_capacity: usize,
|
|
observation_capacity: usize,
|
|
shutdown_timeout: Duration,
|
|
) -> Result<Self, BehaviorError> {
|
|
Self::with_random(
|
|
settings,
|
|
sink,
|
|
queue_capacity,
|
|
observation_capacity,
|
|
shutdown_timeout,
|
|
Arc::new(SystemBehaviorRandom::new()),
|
|
)
|
|
}
|
|
|
|
pub fn with_random(
|
|
settings: BehaviorSettings,
|
|
sink: Arc<dyn EmbodimentSink>,
|
|
queue_capacity: usize,
|
|
observation_capacity: usize,
|
|
shutdown_timeout: Duration,
|
|
random: Arc<dyn BehaviorRandom>,
|
|
) -> Result<Self, BehaviorError> {
|
|
if !settings.is_valid() {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
if queue_capacity == 0
|
|
|| queue_capacity > 8_192
|
|
|| observation_capacity == 0
|
|
|| observation_capacity > 8_192
|
|
|| shutdown_timeout.is_zero()
|
|
|| shutdown_timeout > Duration::from_mins(1)
|
|
{
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
Ok(Self {
|
|
settings,
|
|
sink,
|
|
queue_capacity,
|
|
observation_capacity,
|
|
shutdown_timeout,
|
|
random,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn start(self) -> BehaviorHandle {
|
|
let (commands, receiver) = mpsc::channel(self.queue_capacity);
|
|
let (ready, ready_rx) = watch::channel(None);
|
|
let (paused, paused_rx) = watch::channel(false);
|
|
let (emergency, emergency_rx) = watch::channel(false);
|
|
let (action_cancel, action_cancel_rx) = watch::channel(0_u64);
|
|
let actions = Arc::new(Mutex::new(ActionRegistry::default()));
|
|
let (observations, observation_rx) = mpsc::channel(self.observation_capacity);
|
|
let ingress = BehaviorIngress {
|
|
commands,
|
|
ready,
|
|
paused,
|
|
emergency,
|
|
action_cancel,
|
|
actions: actions.clone(),
|
|
next_action: Arc::new(AtomicU64::new(1)),
|
|
action_capacity: self.queue_capacity,
|
|
};
|
|
let shutdown_timeout = self.shutdown_timeout;
|
|
let task = tokio::spawn(run_actor(
|
|
self,
|
|
receiver,
|
|
ready_rx,
|
|
paused_rx,
|
|
emergency_rx,
|
|
action_cancel_rx,
|
|
actions,
|
|
observations,
|
|
));
|
|
BehaviorHandle {
|
|
ingress,
|
|
observations: observation_rx,
|
|
task: Some(task),
|
|
shutdown_timeout,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait BehaviorRandom: Send + Sync + 'static {
|
|
fn next_u64(&self) -> u64;
|
|
}
|
|
|
|
struct SystemBehaviorRandom(AtomicU64);
|
|
|
|
impl SystemBehaviorRandom {
|
|
fn new() -> Self {
|
|
let seed = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_or(1, |value| {
|
|
u64::try_from(value.as_nanos()).unwrap_or(u64::MAX)
|
|
})
|
|
| 1;
|
|
Self(AtomicU64::new(seed))
|
|
}
|
|
}
|
|
|
|
impl BehaviorRandom for SystemBehaviorRandom {
|
|
fn next_u64(&self) -> u64 {
|
|
let mut current = self.0.load(Ordering::Relaxed);
|
|
loop {
|
|
let mut next = current;
|
|
next ^= next << 13;
|
|
next ^= next >> 7;
|
|
next ^= next << 17;
|
|
match self
|
|
.0
|
|
.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed)
|
|
{
|
|
Ok(_) => return next,
|
|
Err(actual) => current = actual,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // One ordered lifecycle owner.
|
|
async fn run_actor(
|
|
controller: BehaviorController,
|
|
mut commands: mpsc::Receiver<Command>,
|
|
mut ready: watch::Receiver<Option<ReadyState>>,
|
|
mut paused: watch::Receiver<bool>,
|
|
mut emergency: watch::Receiver<bool>,
|
|
action_cancel: watch::Receiver<u64>,
|
|
actions: Arc<Mutex<ActionRegistry>>,
|
|
observations: mpsc::Sender<BehaviorObservation>,
|
|
) {
|
|
let mut mode = BehaviorMode::Offline;
|
|
let mut active_ready: Option<ReadyState> = None;
|
|
let mut last_action: Option<Instant> = None;
|
|
let idle = tokio::time::sleep(controller.settings.idle_interval);
|
|
tokio::pin!(idle);
|
|
loop {
|
|
tokio::select! {
|
|
changed = ready.changed() => {
|
|
if changed.is_err() { break; }
|
|
let next_ready = *ready.borrow();
|
|
let trigger = match (active_ready, next_ready) {
|
|
(Some(previous), Some(next)) if previous.region_id != next.region_id => BehaviorTrigger::RegionChanged,
|
|
(_, Some(_)) => BehaviorTrigger::SessionReady,
|
|
_ => BehaviorTrigger::SessionDisconnected,
|
|
};
|
|
if let Some(previous) = active_ready.filter(|previous| Some(*previous) != next_ready) {
|
|
let _ = controller.sink.stop(previous.generation, CancellationToken::default()).await;
|
|
}
|
|
active_ready = next_ready;
|
|
let next = if *paused.borrow() { BehaviorMode::Paused } else if next_ready.is_some() { BehaviorMode::Settling } else { BehaviorMode::Offline };
|
|
transition(&observations, &mut mode, next, trigger).await;
|
|
if let Some(state) = next_ready {
|
|
tokio::time::sleep(controller.settings.settle_delay).await;
|
|
if ready.borrow().as_ref() == Some(&state) && !*paused.borrow() && !*emergency.borrow() {
|
|
transition(&observations, &mut mode, BehaviorMode::Available, BehaviorTrigger::SessionReady).await;
|
|
}
|
|
}
|
|
idle.as_mut().reset(tokio::time::Instant::now() + controller.settings.idle_interval);
|
|
}
|
|
changed = paused.changed() => {
|
|
if changed.is_err() { break; }
|
|
if *paused.borrow() {
|
|
let current_ready = *ready.borrow();
|
|
if let Some(state) = current_ready { let _ = controller.sink.stop(state.generation, CancellationToken::default()).await; }
|
|
transition(&observations, &mut mode, BehaviorMode::Paused, BehaviorTrigger::OperatorPause).await;
|
|
} else {
|
|
let next = if ready.borrow().is_some() { BehaviorMode::Available } else { BehaviorMode::Offline };
|
|
transition(&observations, &mut mode, next, BehaviorTrigger::OperatorResume).await;
|
|
}
|
|
}
|
|
changed = emergency.changed() => {
|
|
if changed.is_err() { break; }
|
|
if *emergency.borrow() {
|
|
let current_ready = *ready.borrow();
|
|
if let Some(state) = current_ready { let _ = controller.sink.stop(state.generation, CancellationToken::default()).await; }
|
|
transition(&observations, &mut mode, BehaviorMode::Paused, BehaviorTrigger::EmergencyStop).await;
|
|
} else {
|
|
let next = if *paused.borrow() {
|
|
BehaviorMode::Paused
|
|
} else if ready.borrow().is_some() {
|
|
BehaviorMode::Available
|
|
} else {
|
|
BehaviorMode::Offline
|
|
};
|
|
transition(&observations, &mut mode, next, BehaviorTrigger::OperatorResume).await;
|
|
}
|
|
}
|
|
() = &mut idle, if controller.settings.idle_look_enabled => {
|
|
if mode == BehaviorMode::Available {
|
|
let (reply, _) = oneshot::channel();
|
|
let action_id = format!("idle-{}", unix_millis_now());
|
|
let registered = {
|
|
let mut registry = lock_actions(&actions);
|
|
registry.active.len() < controller.queue_capacity
|
|
&& registry.active.insert(action_id.clone())
|
|
};
|
|
if registered {
|
|
let request = ActionRequest { action_id, action: BehaviorAction::LookAround, trigger: BehaviorTrigger::IdleTimer, policy: BehaviorPolicyResult::InternalIdle, reply };
|
|
execute_request(&controller, request, &mut mode, &mut last_action, &ready, &paused, &emergency, &action_cancel, &actions, &observations).await;
|
|
}
|
|
}
|
|
idle.as_mut().reset(tokio::time::Instant::now() + controller.settings.idle_interval);
|
|
}
|
|
command = commands.recv() => {
|
|
let Some(command) = command else { break; };
|
|
match command {
|
|
Command::Shutdown(reply) => {
|
|
let current_ready = *ready.borrow();
|
|
if let Some(state) = current_ready { let _ = controller.sink.stop(state.generation, CancellationToken::default()).await; }
|
|
let _ = reply.send(());
|
|
break;
|
|
}
|
|
Command::Roaming(roaming) => {
|
|
let next = if *paused.borrow() {
|
|
BehaviorMode::Paused
|
|
} else if ready.borrow().is_none() {
|
|
BehaviorMode::Offline
|
|
} else if roaming {
|
|
BehaviorMode::Roaming
|
|
} else {
|
|
BehaviorMode::Available
|
|
};
|
|
transition(&observations, &mut mode, next, BehaviorTrigger::IdleTimer).await;
|
|
}
|
|
Command::Action(request) => execute_request(&controller, request, &mut mode, &mut last_action, &ready, &paused, &emergency, &action_cancel, &actions, &observations).await,
|
|
Command::Attention { action_id, delivery_id, avatar_id, reply } => {
|
|
let request = ActionRequest {
|
|
action_id,
|
|
action: BehaviorAction::FaceAvatar(avatar_id),
|
|
trigger: BehaviorTrigger::PublicResponse { delivery_id, avatar_id },
|
|
policy: BehaviorPolicyResult::BuiltInAttention,
|
|
reply: response_reply(reply),
|
|
};
|
|
let delay = random_duration(&controller, controller.settings.response_delay_min, controller.settings.response_delay_max);
|
|
tokio::time::sleep(delay).await;
|
|
execute_request(&controller, request, &mut mode, &mut last_action, &ready, &paused, &emergency, &action_cancel, &actions, &observations).await;
|
|
if mode == BehaviorMode::Engaged {
|
|
tokio::time::sleep(controller.settings.attention_dwell).await;
|
|
if !*paused.borrow() && ready.borrow().is_some() { transition(&observations, &mut mode, BehaviorMode::Available, BehaviorTrigger::IdleTimer).await; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn response_reply(
|
|
reply: oneshot::Sender<Result<(), BehaviorError>>,
|
|
) -> oneshot::Sender<Result<Value, BehaviorError>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
tokio::spawn(async move {
|
|
let result = rx
|
|
.await
|
|
.unwrap_or(Err(BehaviorError::QueueClosed))
|
|
.map(|_| ());
|
|
let _ = reply.send(result);
|
|
});
|
|
tx
|
|
}
|
|
|
|
async fn transition(
|
|
observations: &mpsc::Sender<BehaviorObservation>,
|
|
mode: &mut BehaviorMode,
|
|
to: BehaviorMode,
|
|
trigger: BehaviorTrigger,
|
|
) {
|
|
if *mode != to {
|
|
let from = *mode;
|
|
*mode = to;
|
|
let _ = observations
|
|
.send(BehaviorObservation::Transition { from, to, trigger })
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
|
async fn execute_request(
|
|
controller: &BehaviorController,
|
|
request: ActionRequest,
|
|
mode: &mut BehaviorMode,
|
|
last_action: &mut Option<Instant>,
|
|
ready: &watch::Receiver<Option<ReadyState>>,
|
|
paused: &watch::Receiver<bool>,
|
|
emergency: &watch::Receiver<bool>,
|
|
action_cancel: &watch::Receiver<u64>,
|
|
actions: &Arc<Mutex<ActionRegistry>>,
|
|
observations: &mpsc::Sender<BehaviorObservation>,
|
|
) {
|
|
let started = Instant::now();
|
|
let action_id = request.action_id.clone();
|
|
let action_name = request.action.name().to_owned();
|
|
let state = *ready.borrow();
|
|
let mut result = if take_action_cancellation(actions, &action_id) {
|
|
Err(BehaviorError::Cancelled)
|
|
} else if *emergency.borrow() {
|
|
Err(BehaviorError::EmergencyStopped)
|
|
} else if *paused.borrow() {
|
|
Err(BehaviorError::Paused)
|
|
} else if state.is_none() {
|
|
Err(BehaviorError::NotReady)
|
|
} else if last_action
|
|
.is_some_and(|last| last.elapsed() < controller.settings.min_action_interval)
|
|
&& !matches!(
|
|
request.action,
|
|
BehaviorAction::Stop | BehaviorAction::CurrentPose
|
|
)
|
|
{
|
|
Err(BehaviorError::RateLimited)
|
|
} else {
|
|
Ok(Value::Null)
|
|
};
|
|
if result.is_ok() {
|
|
let state = state.expect("checked ready state");
|
|
let target_mode = if matches!(request.policy, BehaviorPolicyResult::BuiltInAttention) {
|
|
BehaviorMode::Engaged
|
|
} else if matches!(request.policy, BehaviorPolicyResult::InternalIdle) {
|
|
BehaviorMode::Available
|
|
} else {
|
|
BehaviorMode::Executing
|
|
};
|
|
transition(observations, mode, target_mode, request.trigger.clone()).await;
|
|
let mut action_ready = ready.clone();
|
|
let mut action_paused = paused.clone();
|
|
let mut action_emergency = emergency.clone();
|
|
let action_cancel = action_cancel.clone();
|
|
let action_registry = actions.clone();
|
|
let cancelled_id = action_id.clone();
|
|
result = tokio::select! {
|
|
value = tokio::time::timeout(controller.settings.action_timeout, perform_action(controller, state, &request.action)) => value.unwrap_or(Err(BehaviorError::TimedOut)),
|
|
_ = action_ready.changed() => Err(BehaviorError::Cancelled),
|
|
_ = action_paused.changed() => Err(BehaviorError::Paused),
|
|
_ = action_emergency.changed() => Err(BehaviorError::EmergencyStopped),
|
|
() = wait_for_action_cancellation(action_cancel, action_registry, cancelled_id) => Err(BehaviorError::Cancelled),
|
|
};
|
|
if result.is_err() && matches!(request.action, BehaviorAction::WalkShort { .. }) {
|
|
let _ = controller
|
|
.sink
|
|
.stop(state.generation, CancellationToken::default())
|
|
.await;
|
|
}
|
|
*last_action = Some(Instant::now());
|
|
if matches!(
|
|
result,
|
|
Err(BehaviorError::TimedOut | BehaviorError::Stuck | BehaviorError::NativeOperation)
|
|
) {
|
|
transition(
|
|
observations,
|
|
mode,
|
|
BehaviorMode::Recovering,
|
|
request.trigger.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
if matches!(*mode, BehaviorMode::Executing | BehaviorMode::Recovering) {
|
|
transition(
|
|
observations,
|
|
mode,
|
|
BehaviorMode::Available,
|
|
request.trigger.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
if matches!(
|
|
result,
|
|
Err(BehaviorError::Paused | BehaviorError::EmergencyStopped)
|
|
) {
|
|
transition(
|
|
observations,
|
|
mode,
|
|
BehaviorMode::Paused,
|
|
request.trigger.clone(),
|
|
)
|
|
.await;
|
|
} else if matches!(result, Err(BehaviorError::Cancelled)) {
|
|
let lifecycle_mode = if ready.borrow().is_some() {
|
|
BehaviorMode::Settling
|
|
} else {
|
|
BehaviorMode::Offline
|
|
};
|
|
transition(observations, mode, lifecycle_mode, request.trigger.clone()).await;
|
|
} else if result.is_err()
|
|
&& matches!(request.policy, BehaviorPolicyResult::BuiltInAttention)
|
|
{
|
|
transition(
|
|
observations,
|
|
mode,
|
|
BehaviorMode::Available,
|
|
request.trigger.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
let outcome = match &result {
|
|
Ok(_) => BehaviorOutcome::Completed,
|
|
Err(BehaviorError::TimedOut) => BehaviorOutcome::TimedOut,
|
|
Err(BehaviorError::Stuck) => BehaviorOutcome::Stuck,
|
|
Err(BehaviorError::Cancelled) => BehaviorOutcome::Cancelled,
|
|
Err(BehaviorError::Paused | BehaviorError::EmergencyStopped) => BehaviorOutcome::Preempted,
|
|
Err(_) => BehaviorOutcome::Rejected,
|
|
};
|
|
let _ = observations
|
|
.send(BehaviorObservation::Action {
|
|
action_id: action_id.clone(),
|
|
generation: state.map(|value| value.generation),
|
|
region_id: state.map(|value| value.region_id),
|
|
action: action_name,
|
|
trigger: request.trigger,
|
|
policy: request.policy,
|
|
duration_millis: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
|
outcome,
|
|
})
|
|
.await;
|
|
let _ = request.reply.send(result);
|
|
unregister_action(actions, &action_id);
|
|
}
|
|
|
|
async fn wait_for_action_cancellation(
|
|
mut signal: watch::Receiver<u64>,
|
|
actions: Arc<Mutex<ActionRegistry>>,
|
|
action_id: String,
|
|
) {
|
|
loop {
|
|
if take_action_cancellation(&actions, &action_id) {
|
|
return;
|
|
}
|
|
if signal.changed().await.is_err() {
|
|
std::future::pending::<()>().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn perform_action(
|
|
controller: &BehaviorController,
|
|
state: ReadyState,
|
|
action: &BehaviorAction,
|
|
) -> Result<Value, BehaviorError> {
|
|
let cancellation = CancellationToken::default();
|
|
match action {
|
|
BehaviorAction::CurrentPose => pose_value(
|
|
&controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation)
|
|
.await?,
|
|
state,
|
|
),
|
|
BehaviorAction::FaceAvatar(avatar_id) => {
|
|
let point = controller
|
|
.sink
|
|
.resolve_avatar(state.generation, *avatar_id, cancellation.clone())
|
|
.await?;
|
|
let pose = controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation.clone())
|
|
.await?;
|
|
validate_pose(&pose, state)?;
|
|
validate_point(
|
|
pose.position,
|
|
point,
|
|
controller.settings.max_attention_distance_meters,
|
|
)?;
|
|
smooth_face(controller, state, &pose, point, cancellation).await?;
|
|
Ok(json!({"status":"completed","target_avatar_id":avatar_id.to_string()}))
|
|
}
|
|
BehaviorAction::FacePoint(point) => {
|
|
let pose = controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation.clone())
|
|
.await?;
|
|
validate_pose(&pose, state)?;
|
|
validate_point(
|
|
pose.position,
|
|
*point,
|
|
controller.settings.max_attention_distance_meters,
|
|
)?;
|
|
smooth_face(controller, state, &pose, *point, cancellation).await?;
|
|
Ok(json!({"status":"completed"}))
|
|
}
|
|
BehaviorAction::LookAround => {
|
|
let pose = controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation.clone())
|
|
.await?;
|
|
validate_pose(&pose, state)?;
|
|
let offset = f64::from(
|
|
u32::try_from(controller.random.next_u64() % 121).expect("bounded random offset"),
|
|
) - 60.0;
|
|
let heading = (pose.heading_degrees + offset).to_radians();
|
|
let point = WorldPosition {
|
|
x: pose.position.x + heading.cos() * 4.0,
|
|
y: pose.position.y + heading.sin() * 4.0,
|
|
z: pose.position.z,
|
|
};
|
|
smooth_face(controller, state, &pose, point, cancellation).await?;
|
|
Ok(json!({"status":"completed"}))
|
|
}
|
|
BehaviorAction::WalkShort {
|
|
heading_degrees,
|
|
distance_meters,
|
|
} => {
|
|
walk(
|
|
controller,
|
|
state,
|
|
*heading_degrees,
|
|
*distance_meters,
|
|
cancellation,
|
|
)
|
|
.await
|
|
}
|
|
BehaviorAction::Stop => {
|
|
controller.sink.stop(state.generation, cancellation).await?;
|
|
Ok(json!({"status":"completed"}))
|
|
}
|
|
BehaviorAction::Sit => {
|
|
controller.sink.sit(state.generation, cancellation).await?;
|
|
Ok(json!({"status":"completed"}))
|
|
}
|
|
BehaviorAction::Stand => {
|
|
controller
|
|
.sink
|
|
.stand(state.generation, cancellation)
|
|
.await?;
|
|
Ok(json!({"status":"completed"}))
|
|
}
|
|
BehaviorAction::CameraSet {
|
|
position,
|
|
target,
|
|
vertical_fov_degrees,
|
|
} => {
|
|
let pose = controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation.clone())
|
|
.await?;
|
|
validate_pose(&pose, state)?;
|
|
validate_point(
|
|
pose.position,
|
|
*position,
|
|
controller.settings.max_attention_distance_meters,
|
|
)?;
|
|
validate_point(
|
|
pose.position,
|
|
*target,
|
|
controller.settings.max_attention_distance_meters,
|
|
)?;
|
|
if distance(*position, *target) < 0.25 || !(20.0..=120.0).contains(vertical_fov_degrees)
|
|
{
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
controller
|
|
.sink
|
|
.set_camera(
|
|
state.generation,
|
|
*position,
|
|
*target,
|
|
*vertical_fov_degrees,
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(
|
|
json!({"status":"completed","position":position,"target":target,"vertical_fov_degrees":vertical_fov_degrees}),
|
|
)
|
|
}
|
|
BehaviorAction::CameraReset => {
|
|
controller
|
|
.sink
|
|
.reset_camera(state.generation, cancellation)
|
|
.await?;
|
|
Ok(json!({"status":"completed"}))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn walk(
|
|
controller: &BehaviorController,
|
|
state: ReadyState,
|
|
heading_degrees: f64,
|
|
distance_meters: f64,
|
|
cancellation: CancellationToken,
|
|
) -> Result<Value, BehaviorError> {
|
|
if !heading_degrees.is_finite()
|
|
|| !distance_meters.is_finite()
|
|
|| !(0.25..=f64::from(controller.settings.max_walk_distance_meters))
|
|
.contains(&distance_meters)
|
|
{
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
let start = controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation.clone())
|
|
.await?;
|
|
validate_pose(&start, state)?;
|
|
let heading = heading_degrees.to_radians();
|
|
let target = WorldPosition {
|
|
x: start.position.x + heading.cos() * distance_meters,
|
|
y: start.position.y + heading.sin() * distance_meters,
|
|
z: start.position.z,
|
|
};
|
|
if target.x < 0.5 || target.y < 0.5 {
|
|
return Err(BehaviorError::RegionBoundary);
|
|
}
|
|
controller
|
|
.sink
|
|
.validate_walk_target(state.generation, target, cancellation.clone())
|
|
.await?;
|
|
smooth_face(controller, state, &start, target, cancellation.clone()).await?;
|
|
controller
|
|
.sink
|
|
.begin_walk(state.generation, target, cancellation.clone())
|
|
.await?;
|
|
let deadline = tokio::time::Instant::now() + controller.settings.max_walk_duration;
|
|
let mut last_progress = tokio::time::Instant::now();
|
|
let mut prior = start.position;
|
|
let outcome = loop {
|
|
tokio::time::sleep(WALK_POLL).await;
|
|
if tokio::time::Instant::now() >= deadline {
|
|
break Err(BehaviorError::TimedOut);
|
|
}
|
|
let pose = match controller
|
|
.sink
|
|
.current_pose(state.generation, cancellation.clone())
|
|
.await
|
|
{
|
|
Ok(pose) => pose,
|
|
Err(error) => break Err(error),
|
|
};
|
|
if validate_pose(&pose, state).is_err() {
|
|
break Err(BehaviorError::RegionBoundary);
|
|
}
|
|
if distance(pose.position, target) <= ARRIVAL_METERS {
|
|
break Ok((pose, true));
|
|
}
|
|
if distance(pose.position, prior) >= STUCK_PROGRESS_METERS {
|
|
prior = pose.position;
|
|
last_progress = tokio::time::Instant::now();
|
|
} else if last_progress.elapsed() >= controller.settings.stuck_timeout {
|
|
break Ok((pose, false));
|
|
}
|
|
};
|
|
let stop_result = controller
|
|
.sink
|
|
.stop(state.generation, CancellationToken::default())
|
|
.await;
|
|
match (outcome, stop_result) {
|
|
(Ok((pose, true)), Ok(())) => pose_value(&pose, state),
|
|
(Ok((pose, false)), Ok(())) => Ok(json!({
|
|
"status":"completed",
|
|
"position_feedback":"unavailable",
|
|
"observed_position":pose.position,
|
|
"commanded_target":target
|
|
})),
|
|
(Err(error), _) | (_, Err(error)) => Err(error),
|
|
}
|
|
}
|
|
|
|
async fn smooth_face(
|
|
controller: &BehaviorController,
|
|
state: ReadyState,
|
|
pose: &EmbodiedPose,
|
|
target: WorldPosition,
|
|
cancellation: CancellationToken,
|
|
) -> Result<(), BehaviorError> {
|
|
let desired = (target.y - pose.position.y)
|
|
.atan2(target.x - pose.position.x)
|
|
.to_degrees()
|
|
.rem_euclid(360.0);
|
|
let delta = (desired - pose.heading_degrees + 540.0).rem_euclid(360.0) - 180.0;
|
|
let steps = (1..=6)
|
|
.find(|step| delta.abs() <= MAX_TURN_STEP_DEGREES * f64::from(*step))
|
|
.unwrap_or(6);
|
|
let horizontal_distance = ((target.x - pose.position.x).powi(2)
|
|
+ (target.y - pose.position.y).powi(2))
|
|
.sqrt()
|
|
.clamp(1.0, 4.0);
|
|
for step in 1..=steps {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err(BehaviorError::Cancelled);
|
|
}
|
|
let point = if step == steps {
|
|
target
|
|
} else {
|
|
let fraction = f64::from(step) / f64::from(steps);
|
|
let heading = (pose.heading_degrees + delta * fraction).to_radians();
|
|
WorldPosition {
|
|
x: pose.position.x + heading.cos() * horizontal_distance,
|
|
y: pose.position.y + heading.sin() * horizontal_distance,
|
|
z: pose.position.z + (target.z - pose.position.z) * fraction,
|
|
}
|
|
};
|
|
controller
|
|
.sink
|
|
.face_point(state.generation, point, cancellation.clone())
|
|
.await?;
|
|
if step != steps {
|
|
tokio::time::sleep(TURN_INTERVAL).await;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn pose_value(pose: &EmbodiedPose, state: ReadyState) -> Result<Value, BehaviorError> {
|
|
validate_pose(pose, state)?;
|
|
Ok(
|
|
json!({"status":"observed","generation":pose.generation,"region_id":pose.region_id.to_string(),"position":pose.position,"heading_degrees":pose.heading_degrees,"sitting":pose.sitting}),
|
|
)
|
|
}
|
|
|
|
fn validate_pose(pose: &EmbodiedPose, state: ReadyState) -> Result<(), BehaviorError> {
|
|
pose.valid(state.generation, state.region_id)
|
|
.then_some(())
|
|
.ok_or(BehaviorError::RegionBoundary)
|
|
}
|
|
|
|
fn validate_point(
|
|
origin: WorldPosition,
|
|
point: WorldPosition,
|
|
maximum: u32,
|
|
) -> Result<(), BehaviorError> {
|
|
if !point.x.is_finite()
|
|
|| !point.y.is_finite()
|
|
|| !point.z.is_finite()
|
|
|| distance(origin, point) > f64::from(maximum)
|
|
|| point.x < 0.0
|
|
|| point.y < 0.0
|
|
{
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn distance(left: WorldPosition, right: WorldPosition) -> f64 {
|
|
((left.x - right.x).powi(2) + (left.y - right.y).powi(2) + (left.z - right.z).powi(2)).sqrt()
|
|
}
|
|
|
|
fn random_duration(
|
|
controller: &BehaviorController,
|
|
minimum: Duration,
|
|
maximum: Duration,
|
|
) -> Duration {
|
|
let range = maximum.saturating_sub(minimum).as_millis();
|
|
if range == 0 {
|
|
return minimum;
|
|
}
|
|
let offset = u128::from(controller.random.next_u64()) % (range + 1);
|
|
minimum + Duration::from_millis(u64::try_from(offset).unwrap_or(u64::MAX))
|
|
}
|
|
|
|
fn valid_action_id(value: &str) -> bool {
|
|
!value.is_empty()
|
|
&& value.len() <= MAX_ACTION_ID_BYTES
|
|
&& value.chars().all(|character| {
|
|
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | ':' | '|')
|
|
})
|
|
}
|
|
|
|
fn lock_actions(value: &Mutex<ActionRegistry>) -> MutexGuard<'_, ActionRegistry> {
|
|
value
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn take_action_cancellation(actions: &Mutex<ActionRegistry>, action_id: &str) -> bool {
|
|
lock_actions(actions).cancelled.remove(action_id)
|
|
}
|
|
|
|
fn unregister_action(actions: &Mutex<ActionRegistry>, action_id: &str) {
|
|
let mut actions = lock_actions(actions);
|
|
actions.active.remove(action_id);
|
|
actions.cancelled.remove(action_id);
|
|
}
|
|
|
|
fn unix_millis_now() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_or(0, |duration| {
|
|
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct BehaviorBackend {
|
|
ingress: BehaviorIngress,
|
|
}
|
|
|
|
struct WalkCost {
|
|
maximum_meters: u32,
|
|
}
|
|
|
|
impl ResourceEstimator for WalkCost {
|
|
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
fn estimate(&self, arguments: &Value) -> Result<ResourceCost, PolicyReasonCode> {
|
|
let distance = arguments
|
|
.get("distance_meters")
|
|
.and_then(Value::as_f64)
|
|
.filter(|value| {
|
|
value.is_finite() && (0.25..=f64::from(self.maximum_meters)).contains(value)
|
|
})
|
|
.ok_or(PolicyReasonCode::InvalidArguments)?;
|
|
Ok(ResourceCost {
|
|
tool_calls: 1,
|
|
movement_millimeters: (distance * 1_000.0).ceil() as u64,
|
|
..ResourceCost::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
impl BehaviorBackend {
|
|
#[must_use]
|
|
pub const fn new(ingress: BehaviorIngress) -> Self {
|
|
Self { ingress }
|
|
}
|
|
}
|
|
|
|
impl AuthorizedToolBackend for BehaviorBackend {
|
|
fn apply(
|
|
&self,
|
|
action: AuthorizedAction,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
|
|
Box::pin(async move {
|
|
let call_id = action.call().call_id.clone();
|
|
let authorization_id = action.authorization_id();
|
|
let parsed = parse_action(
|
|
action.call().name.as_str(),
|
|
action.call().arguments_json.as_str(),
|
|
);
|
|
let result = match parsed {
|
|
Ok(behavior_action) => tokio::select! {
|
|
() = cancellation.cancelled() => Err(BehaviorError::Cancelled),
|
|
value = self.ingress.request(call_id.as_str().to_owned(), behavior_action, BehaviorTrigger::AuthorizedTool { authorization_id, tool: action.call().name.as_str().to_owned() }, BehaviorPolicyResult::Authorized(authorization_id)) => value,
|
|
},
|
|
Err(error) => Err(error),
|
|
};
|
|
Ok(match result {
|
|
Ok(value) => {
|
|
let serialized =
|
|
serde_json::to_string(&value).map_err(|_| BackendError::Operation {
|
|
operation: "serialize behavior result",
|
|
})?;
|
|
if serialized.len() > MAX_TOOL_RESULT_BYTES {
|
|
return Err(BackendError::Operation {
|
|
operation: "bounded behavior result",
|
|
});
|
|
}
|
|
ToolCallOutcome::Completed {
|
|
call_id,
|
|
result: BoundedText::<MAX_BODY_BYTES>::new("behavior.result", serialized)
|
|
.map_err(|_| BackendError::Operation {
|
|
operation: "bounded behavior result",
|
|
})?,
|
|
}
|
|
}
|
|
Err(error) => ToolCallOutcome::Rejected {
|
|
call_id,
|
|
reason: BoundedText::<MAX_OBSERVABLE_DETAIL_BYTES>::new(
|
|
"behavior.rejection",
|
|
error.to_string(),
|
|
)
|
|
.map_err(|_| BackendError::Operation {
|
|
operation: "bounded behavior rejection",
|
|
})?,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_action(name: &str, json_arguments: &str) -> Result<BehaviorAction, BehaviorError> {
|
|
let value: Value =
|
|
serde_json::from_str(json_arguments).map_err(|_| BehaviorError::InvalidArguments)?;
|
|
let args = value.as_object().ok_or(BehaviorError::InvalidArguments)?;
|
|
match name {
|
|
FACE_AVATAR_TOOL => Ok(BehaviorAction::FaceAvatar(parse_uuid(args, "avatar_id")?)),
|
|
FACE_POINT_TOOL => Ok(BehaviorAction::FacePoint(parse_point(args)?)),
|
|
LOOK_AROUND_TOOL => {
|
|
require_empty(args)?;
|
|
Ok(BehaviorAction::LookAround)
|
|
}
|
|
WALK_SHORT_TOOL => Ok(BehaviorAction::WalkShort {
|
|
heading_degrees: number(args, "heading_degrees")?,
|
|
distance_meters: number(args, "distance_meters")?,
|
|
}),
|
|
STOP_TOOL => {
|
|
require_empty(args)?;
|
|
Ok(BehaviorAction::Stop)
|
|
}
|
|
SIT_TOOL => {
|
|
require_empty(args)?;
|
|
Ok(BehaviorAction::Sit)
|
|
}
|
|
STAND_TOOL => {
|
|
require_empty(args)?;
|
|
Ok(BehaviorAction::Stand)
|
|
}
|
|
CURRENT_POSE_TOOL => {
|
|
require_empty(args)?;
|
|
Ok(BehaviorAction::CurrentPose)
|
|
}
|
|
CAMERA_SET_TOOL => {
|
|
if args.len() != 7 {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
Ok(BehaviorAction::CameraSet {
|
|
position: WorldPosition {
|
|
x: number(args, "position_x")?,
|
|
y: number(args, "position_y")?,
|
|
z: number(args, "position_z")?,
|
|
},
|
|
target: WorldPosition {
|
|
x: number(args, "target_x")?,
|
|
y: number(args, "target_y")?,
|
|
z: number(args, "target_z")?,
|
|
},
|
|
vertical_fov_degrees: number(args, "vertical_fov_degrees")?,
|
|
})
|
|
}
|
|
CAMERA_RESET_TOOL => {
|
|
require_empty(args)?;
|
|
Ok(BehaviorAction::CameraReset)
|
|
}
|
|
_ => Err(BehaviorError::InvalidArguments),
|
|
}
|
|
}
|
|
|
|
fn parse_uuid(args: &Map<String, Value>, name: &str) -> Result<UUID, BehaviorError> {
|
|
let text = args
|
|
.get(name)
|
|
.and_then(Value::as_str)
|
|
.ok_or(BehaviorError::InvalidArguments)?;
|
|
let id = UUID::parse(text.to_owned()).map_err(|_| BehaviorError::InvalidArguments)?;
|
|
(id != UUID::zero())
|
|
.then_some(id)
|
|
.ok_or(BehaviorError::InvalidArguments)
|
|
}
|
|
|
|
fn parse_point(args: &Map<String, Value>) -> Result<WorldPosition, BehaviorError> {
|
|
if args.len() != 3 {
|
|
return Err(BehaviorError::InvalidArguments);
|
|
}
|
|
Ok(WorldPosition {
|
|
x: number(args, "x")?,
|
|
y: number(args, "y")?,
|
|
z: number(args, "z")?,
|
|
})
|
|
}
|
|
|
|
fn number(args: &Map<String, Value>, name: &str) -> Result<f64, BehaviorError> {
|
|
args.get(name)
|
|
.and_then(Value::as_f64)
|
|
.filter(|value| value.is_finite())
|
|
.ok_or(BehaviorError::InvalidArguments)
|
|
}
|
|
|
|
fn require_empty(args: &Map<String, Value>) -> Result<(), BehaviorError> {
|
|
args.is_empty()
|
|
.then_some(())
|
|
.ok_or(BehaviorError::InvalidArguments)
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)] // One registry table keeps the ten schemas auditable together.
|
|
pub fn behavior_policy_tools(settings: &BehaviorSettings) -> Result<Vec<PolicyTool>, PolicyError> {
|
|
let origins = || {
|
|
AllowedOrigins::new([
|
|
OriginClass::AuthorizedIm,
|
|
OriginClass::LocalOperator,
|
|
OriginClass::InternalScheduler,
|
|
])
|
|
};
|
|
let empty = ToolSchema::Object {
|
|
properties: BTreeMap::new(),
|
|
required: BTreeSet::new(),
|
|
additional_properties: false,
|
|
};
|
|
let object = |properties: &[(&str, ToolSchema)], required: &[&str]| ToolSchema::Object {
|
|
properties: properties
|
|
.iter()
|
|
.map(|(name, schema)| ((*name).to_owned(), schema.clone()))
|
|
.collect(),
|
|
required: required.iter().map(|name| (*name).to_owned()).collect(),
|
|
additional_properties: false,
|
|
};
|
|
let specs = [
|
|
(
|
|
FACE_AVATAR_TOOL,
|
|
"Turn attention toward a currently visible avatar.",
|
|
object(&[("avatar_id", ToolSchema::String)], &["avatar_id"]),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
FACE_POINT_TOOL,
|
|
"Turn attention toward a nearby point in the current region.",
|
|
object(
|
|
&[
|
|
("x", ToolSchema::Number),
|
|
("y", ToolSchema::Number),
|
|
("z", ToolSchema::Number),
|
|
],
|
|
&["x", "y", "z"],
|
|
),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
LOOK_AROUND_TOOL,
|
|
"Make one small bounded attention shift.",
|
|
empty.clone(),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
WALK_SHORT_TOOL,
|
|
"Walk a short bounded distance on a heading, then stop.",
|
|
object(
|
|
&[
|
|
("heading_degrees", ToolSchema::Number),
|
|
("distance_meters", ToolSchema::Number),
|
|
],
|
|
&["heading_degrees", "distance_meters"],
|
|
),
|
|
true,
|
|
u64::from(settings.max_walk_distance_meters) * 1_000,
|
|
),
|
|
(
|
|
STOP_TOOL,
|
|
"Immediately stop current bounded movement.",
|
|
empty.clone(),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
SIT_TOOL,
|
|
"Sit on the ground when supported.",
|
|
empty.clone(),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
STAND_TOOL,
|
|
"Stand from the current seated pose.",
|
|
empty.clone(),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
CURRENT_POSE_TOOL,
|
|
"Read current embodied pose and session provenance.",
|
|
empty.clone(),
|
|
false,
|
|
0,
|
|
),
|
|
(
|
|
CAMERA_SET_TOOL,
|
|
"Place the camera near the avatar and look at a point. Use this to focus or orbit objects; coordinates are current-region meters.",
|
|
object(
|
|
&[
|
|
("position_x", ToolSchema::Number),
|
|
("position_y", ToolSchema::Number),
|
|
("position_z", ToolSchema::Number),
|
|
("target_x", ToolSchema::Number),
|
|
("target_y", ToolSchema::Number),
|
|
("target_z", ToolSchema::Number),
|
|
("vertical_fov_degrees", ToolSchema::Number),
|
|
],
|
|
&[
|
|
"position_x",
|
|
"position_y",
|
|
"position_z",
|
|
"target_x",
|
|
"target_y",
|
|
"target_z",
|
|
"vertical_fov_degrees",
|
|
],
|
|
),
|
|
true,
|
|
0,
|
|
),
|
|
(
|
|
CAMERA_RESET_TOOL,
|
|
"Reset the camera to the avatar position and facing direction.",
|
|
empty,
|
|
true,
|
|
0,
|
|
),
|
|
];
|
|
specs
|
|
.into_iter()
|
|
.map(|(name, description, schema, mutating, movement_mm)| {
|
|
let cost = ResourceCost {
|
|
tool_calls: 1,
|
|
movement_millimeters: movement_mm,
|
|
..ResourceCost::default()
|
|
};
|
|
let estimator: Arc<dyn ResourceEstimator> = if name == WALK_SHORT_TOOL {
|
|
Arc::new(WalkCost {
|
|
maximum_meters: settings.max_walk_distance_meters,
|
|
})
|
|
} else {
|
|
Arc::new(FixedCost(cost))
|
|
};
|
|
PolicyTool::new(
|
|
ToolDefinition {
|
|
name: BoundedText::new("behavior.tool.name", name)?,
|
|
description: BoundedText::new("behavior.tool.description", description)?,
|
|
schema,
|
|
mutating,
|
|
},
|
|
if mutating {
|
|
Capability::Movement
|
|
} else {
|
|
Capability::Informational
|
|
},
|
|
if mutating {
|
|
Risk::Movement
|
|
} else {
|
|
Risk::ReadOnly
|
|
},
|
|
origins()?,
|
|
cost,
|
|
Idempotency::Idempotent,
|
|
ApprovalRule::Never,
|
|
true,
|
|
estimator,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Exact-name router used when one policy gateway serves perception and behavior.
|
|
pub struct AuthorizedBackendRouter {
|
|
routes: BTreeMap<String, Arc<dyn AuthorizedToolBackend>>,
|
|
}
|
|
|
|
impl AuthorizedBackendRouter {
|
|
pub fn new(
|
|
routes: impl IntoIterator<Item = (String, Arc<dyn AuthorizedToolBackend>)>,
|
|
) -> Result<Self, BackendError> {
|
|
let mut mapped = BTreeMap::new();
|
|
for (name, backend) in routes {
|
|
if name.is_empty() || mapped.insert(name, backend).is_some() {
|
|
return Err(BackendError::Configuration {
|
|
component: "authorized backend routes",
|
|
});
|
|
}
|
|
}
|
|
if mapped.is_empty() {
|
|
return Err(BackendError::Configuration {
|
|
component: "authorized backend routes",
|
|
});
|
|
}
|
|
Ok(Self { routes: mapped })
|
|
}
|
|
}
|
|
|
|
impl AuthorizedToolBackend for AuthorizedBackendRouter {
|
|
fn apply(
|
|
&self,
|
|
action: AuthorizedAction,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
|
|
Box::pin(async move {
|
|
let backend = self
|
|
.routes
|
|
.get(action.call().name.as_str())
|
|
.ok_or(BackendError::RejectedMutation)?;
|
|
backend.apply(action, cancellation).await
|
|
})
|
|
}
|
|
}
|