Implement pure Rust visual snapshots (#132)
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -2270,10 +2270,14 @@ dependencies = [
|
|||||||
name = "metacrate-grid-agent"
|
name = "metacrate-grid-agent"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
|
"libremetaverse-imaging",
|
||||||
|
"libremetaverse-rendering-simple",
|
||||||
"libremetaverse-types",
|
"libremetaverse-types",
|
||||||
"metacrate-lsl-tools",
|
"metacrate-lsl-tools",
|
||||||
|
"png",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ description = "Bounded pure-Rust OpenSim grid-agent service foundation"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false, optional = true }
|
libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false, optional = true }
|
||||||
|
libremetaverse-imaging = { version = "0.0.1", path = "../libremetaverse-imaging", default-features = false, features = ["rust-j2k"], optional = true }
|
||||||
|
libremetaverse-rendering-simple = { version = "0.0.1", path = "../libremetaverse-rendering-simple", optional = true }
|
||||||
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
||||||
metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" }
|
metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" }
|
||||||
|
png = "0.17"
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
|
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
|
||||||
rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -31,7 +35,7 @@ tokio = { version = "1.53.1", features = ["io-util", "net", "rt-multi-thread", "
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
live-grid = ["dep:libremetaverse"]
|
live-grid = ["dep:libremetaverse", "dep:libremetaverse-imaging", "dep:libremetaverse-rendering-simple"]
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ pub struct RuntimeView {
|
|||||||
pub active_build_transaction: Option<String>,
|
pub active_build_transaction: Option<String>,
|
||||||
pub build_progress: Option<String>,
|
pub build_progress: Option<String>,
|
||||||
pub build_orphan_ids: Vec<String>,
|
pub build_orphan_ids: Vec<String>,
|
||||||
|
pub active_visual_capture: Option<String>,
|
||||||
|
pub visual_progress: Option<String>,
|
||||||
|
pub visual_image_sha256: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ impl ControlTarget for FakeTarget {
|
|||||||
active_build_transaction: None,
|
active_build_transaction: None,
|
||||||
build_progress: None,
|
build_progress: None,
|
||||||
build_orphan_ids: Vec::new(),
|
build_orphan_ids: Vec::new(),
|
||||||
|
active_visual_capture: None,
|
||||||
|
visual_progress: None,
|
||||||
|
visual_image_sha256: None,
|
||||||
})),
|
})),
|
||||||
ControlRequest::ListSessions { page } => Ok(ControlPayload::Sessions(page_values(
|
ControlRequest::ListSessions { page } => Ok(ControlPayload::Sessions(page_values(
|
||||||
&page,
|
&page,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub struct AgentControlTarget {
|
|||||||
audit: Arc<MemoryPolicyAudit>,
|
audit: Arc<MemoryPolicyAudit>,
|
||||||
observability: Mutex<Option<Arc<Observability>>>,
|
observability: Mutex<Option<Arc<Observability>>>,
|
||||||
build: Mutex<Option<Arc<dyn crate::build::BuildControl>>>,
|
build: Mutex<Option<Arc<dyn crate::build::BuildControl>>>,
|
||||||
|
vision: Mutex<Option<Arc<dyn crate::vision::VisionControl>>>,
|
||||||
behavior: BehaviorIngress,
|
behavior: BehaviorIngress,
|
||||||
commands: mpsc::Sender<RuntimeControlCommand>,
|
commands: mpsc::Sender<RuntimeControlCommand>,
|
||||||
command_capacity: usize,
|
command_capacity: usize,
|
||||||
@@ -129,6 +130,7 @@ impl AgentControlTarget {
|
|||||||
audit,
|
audit,
|
||||||
observability: Mutex::new(None),
|
observability: Mutex::new(None),
|
||||||
build: Mutex::new(None),
|
build: Mutex::new(None),
|
||||||
|
vision: Mutex::new(None),
|
||||||
behavior,
|
behavior,
|
||||||
commands,
|
commands,
|
||||||
command_capacity,
|
command_capacity,
|
||||||
@@ -149,6 +151,11 @@ impl AgentControlTarget {
|
|||||||
*lock(&self.build) = Some(build);
|
*lock(&self.build) = Some(build);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches synthetic viewport cancellation and metadata to operator control.
|
||||||
|
pub fn attach_vision_control(&self, vision: Arc<dyn crate::vision::VisionControl>) {
|
||||||
|
*lock(&self.vision) = Some(vision);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_session(&self, session: SessionStatus) {
|
pub fn update_session(&self, session: SessionStatus) {
|
||||||
let mut state = lock(&self.state);
|
let mut state = lock(&self.state);
|
||||||
state.session = session;
|
state.session = session;
|
||||||
@@ -245,6 +252,7 @@ impl AgentControlTarget {
|
|||||||
let state = lock(&self.state).clone();
|
let state = lock(&self.state).clone();
|
||||||
let usage = self.policy.global_budget_usage();
|
let usage = self.policy.global_budget_usage();
|
||||||
let build = lock(&self.build);
|
let build = lock(&self.build);
|
||||||
|
let vision = lock(&self.vision);
|
||||||
Ok(ControlPayload::Runtime(RuntimeView {
|
Ok(ControlPayload::Runtime(RuntimeView {
|
||||||
grid_state: state.session.state.as_str().to_owned(),
|
grid_state: state.session.state.as_str().to_owned(),
|
||||||
generation: state.session.generation,
|
generation: state.session.generation,
|
||||||
@@ -265,6 +273,13 @@ impl AgentControlTarget {
|
|||||||
build_orphan_ids: build
|
build_orphan_ids: build
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(Vec::new, |build| build.build_orphans()),
|
.map_or_else(Vec::new, |build| build.build_orphans()),
|
||||||
|
active_visual_capture: vision
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|vision| vision.active_capture()),
|
||||||
|
visual_progress: vision.as_ref().and_then(|vision| vision.capture_progress()),
|
||||||
|
visual_image_sha256: vision
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|vision| vision.last_image_sha256()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
ControlRequest::ListSessions { page } => {
|
ControlRequest::ListSessions { page } => {
|
||||||
@@ -345,6 +360,12 @@ impl AgentControlTarget {
|
|||||||
ControlRequest::PauseAutonomy => {
|
ControlRequest::PauseAutonomy => {
|
||||||
let response = self.enqueue("pause", RuntimeControlCommand::Pause)?;
|
let response = self.enqueue("pause", RuntimeControlCommand::Pause)?;
|
||||||
self.behavior.pause();
|
self.behavior.pause();
|
||||||
|
let vision = lock(&self.vision).clone();
|
||||||
|
if let Some((vision, active)) =
|
||||||
|
vision.and_then(|vision| vision.active_capture().map(|active| (vision, active)))
|
||||||
|
{
|
||||||
|
let _ = vision.cancel_capture(&active);
|
||||||
|
}
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
ControlRequest::ResumeAutonomy => {
|
ControlRequest::ResumeAutonomy => {
|
||||||
@@ -356,10 +377,13 @@ impl AgentControlTarget {
|
|||||||
let build_cancelled = lock(&self.build)
|
let build_cancelled = lock(&self.build)
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|build| build.cancel_build(&action_id));
|
.is_some_and(|build| build.cancel_build(&action_id));
|
||||||
|
let vision_cancelled = lock(&self.vision)
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|vision| vision.cancel_capture(&action_id));
|
||||||
let cancelled = self.behavior.cancel_action(&action_id).map_err(|_| {
|
let cancelled = self.behavior.cancel_action(&action_id).map_err(|_| {
|
||||||
control_error(ControlErrorCode::InvalidRequest, "invalid action ID", false)
|
control_error(ControlErrorCode::InvalidRequest, "invalid action ID", false)
|
||||||
})?;
|
})?;
|
||||||
if !cancelled && !build_cancelled {
|
if !cancelled && !build_cancelled && !vision_cancelled {
|
||||||
return Err(control_error(
|
return Err(control_error(
|
||||||
ControlErrorCode::NotFound,
|
ControlErrorCode::NotFound,
|
||||||
"active behavior action not found",
|
"active behavior action not found",
|
||||||
|
|||||||
@@ -7,15 +7,39 @@ use crate::control_runtime::{AgentControlTarget, RuntimeControlCommand};
|
|||||||
use crate::conversation::{ConversationLimits, ConversationStore};
|
use crate::conversation::{ConversationLimits, ConversationStore};
|
||||||
use crate::perception::WorldPosition;
|
use crate::perception::WorldPosition;
|
||||||
use crate::policy::{MemoryPolicyAudit, PolicyAuditSink, PolicyGateway, PolicyLimits};
|
use crate::policy::{MemoryPolicyAudit, PolicyAuditSink, PolicyGateway, PolicyLimits};
|
||||||
use crate::{AgentConfig, EmbodiedPose, Observability, ObservabilityLimits, SecretString};
|
use crate::{
|
||||||
|
AgentConfig, EmbodiedPose, Observability, ObservabilityLimits, SecretString, VisionControl,
|
||||||
|
};
|
||||||
use libremetaverse_types::UUID;
|
use libremetaverse_types::UUID;
|
||||||
use libremetaverse_types::compat::CancellationToken;
|
use libremetaverse_types::compat::CancellationToken;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
struct Sink;
|
struct Sink;
|
||||||
|
|
||||||
|
struct FakeVision(Mutex<Option<String>>);
|
||||||
|
impl crate::VisionControl for FakeVision {
|
||||||
|
fn cancel_capture(&self, correlation_id: &str) -> bool {
|
||||||
|
let mut active = self.0.lock().unwrap();
|
||||||
|
if active.as_deref() == Some(correlation_id) {
|
||||||
|
*active = None;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn active_capture(&self) -> Option<String> {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
fn capture_progress(&self) -> Option<String> {
|
||||||
|
self.active_capture().map(|_| "rendering".into())
|
||||||
|
}
|
||||||
|
fn last_image_sha256(&self) -> Option<String> {
|
||||||
|
Some("abc123".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl EmbodimentSink for Sink {
|
impl EmbodimentSink for Sink {
|
||||||
fn current_pose(
|
fn current_pose(
|
||||||
&self,
|
&self,
|
||||||
@@ -99,6 +123,7 @@ fn envelope(id: &str, request: ControlRequest) -> ControlRequestEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
async fn production_target_projects_state_and_routes_real_mutations() {
|
async fn production_target_projects_state_and_routes_real_mutations() {
|
||||||
let settings = AgentConfig::offline("https://llm.invalid/chat", "llm-key")
|
let settings = AgentConfig::offline("https://llm.invalid/chat", "llm-key")
|
||||||
.expect("offline config")
|
.expect("offline config")
|
||||||
@@ -125,6 +150,8 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
|||||||
.expect("target");
|
.expect("target");
|
||||||
let observability = Observability::memory(ObservabilityLimits::default()).expect("events");
|
let observability = Observability::memory(ObservabilityLimits::default()).expect("events");
|
||||||
target.attach_observability(observability);
|
target.attach_observability(observability);
|
||||||
|
let vision = Arc::new(FakeVision(Mutex::new(Some("visual-1".into()))));
|
||||||
|
target.attach_vision_control(vision.clone());
|
||||||
target.update_region(
|
target.update_region(
|
||||||
Some("00000000-0000-4000-8000-000000000001".into()),
|
Some("00000000-0000-4000-8000-000000000001".into()),
|
||||||
Some("Test Region".into()),
|
Some("Test Region".into()),
|
||||||
@@ -146,6 +173,9 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
|||||||
};
|
};
|
||||||
assert_eq!(runtime.region_name.as_deref(), Some("Test Region"));
|
assert_eq!(runtime.region_name.as_deref(), Some("Test Region"));
|
||||||
assert_eq!(runtime.control_queue_capacity, 8);
|
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"));
|
||||||
|
assert_eq!(runtime.visual_image_sha256.as_deref(), Some("abc123"));
|
||||||
let metrics = operator
|
let metrics = operator
|
||||||
.request(envelope("metrics", ControlRequest::Metrics))
|
.request(envelope("metrics", ControlRequest::Metrics))
|
||||||
.await;
|
.await;
|
||||||
@@ -165,6 +195,7 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
|||||||
commands.recv().await,
|
commands.recv().await,
|
||||||
Some(RuntimeControlCommand::Pause)
|
Some(RuntimeControlCommand::Pause)
|
||||||
));
|
));
|
||||||
|
assert!(vision.active_capture().is_none(), "pause cancels capture");
|
||||||
|
|
||||||
let observer = plane.connect("observer-token").expect("observer");
|
let observer = plane.connect("observer-token").expect("observer");
|
||||||
let denied = observer
|
let denied = observer
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ pub enum InteractionModelError {
|
|||||||
Timeout,
|
Timeout,
|
||||||
Failed,
|
Failed,
|
||||||
PolicyRejected,
|
PolicyRejected,
|
||||||
|
MultimodalUnsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InteractionModelError {
|
impl fmt::Display for InteractionModelError {
|
||||||
@@ -305,6 +306,7 @@ impl fmt::Display for InteractionModelError {
|
|||||||
Self::Timeout => "interaction model request timed out",
|
Self::Timeout => "interaction model request timed out",
|
||||||
Self::Failed => "interaction model request failed",
|
Self::Failed => "interaction model request failed",
|
||||||
Self::PolicyRejected => "interaction request was rejected by policy",
|
Self::PolicyRejected => "interaction request was rejected by policy",
|
||||||
|
Self::MultimodalUnsupported => "configured endpoint does not support visual context",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1152,7 +1154,9 @@ async fn process_batch(
|
|||||||
Ok(_) => InferenceOutcome::Completed,
|
Ok(_) => InferenceOutcome::Completed,
|
||||||
Err(InteractionModelError::Cancelled) => InferenceOutcome::Cancelled,
|
Err(InteractionModelError::Cancelled) => InferenceOutcome::Cancelled,
|
||||||
Err(InteractionModelError::Timeout) => InferenceOutcome::TimedOut,
|
Err(InteractionModelError::Timeout) => InferenceOutcome::TimedOut,
|
||||||
Err(InteractionModelError::Failed) => InferenceOutcome::Failed,
|
Err(InteractionModelError::Failed | InteractionModelError::MultimodalUnsupported) => {
|
||||||
|
InferenceOutcome::Failed
|
||||||
|
}
|
||||||
Err(InteractionModelError::PolicyRejected) => InferenceOutcome::PolicyRejected,
|
Err(InteractionModelError::PolicyRejected) => InferenceOutcome::PolicyRejected,
|
||||||
};
|
};
|
||||||
observe(
|
observe(
|
||||||
@@ -1249,6 +1253,7 @@ async fn process_batch(
|
|||||||
InteractionModelError::Cancelled | InteractionModelError::Failed => {
|
InteractionModelError::Cancelled | InteractionModelError::Failed => {
|
||||||
DeliveryOutcome::Failed
|
DeliveryOutcome::Failed
|
||||||
}
|
}
|
||||||
|
InteractionModelError::MultimodalUnsupported => DeliveryOutcome::Failed,
|
||||||
};
|
};
|
||||||
let delivered = if error == InteractionModelError::Cancelled {
|
let delivered = if error == InteractionModelError::Cancelled {
|
||||||
delivery_observation(
|
delivery_observation(
|
||||||
@@ -1822,6 +1827,9 @@ impl InteractionResponder for PolicyLlmResponder {
|
|||||||
crate::tool_loop::ToolLoopError::WallClockTimeout => {
|
crate::tool_loop::ToolLoopError::WallClockTimeout => {
|
||||||
InteractionModelError::Timeout
|
InteractionModelError::Timeout
|
||||||
}
|
}
|
||||||
|
crate::tool_loop::ToolLoopError::Transport(
|
||||||
|
crate::llm::LlmError::MultimodalUnsupported,
|
||||||
|
) => InteractionModelError::MultimodalUnsupported,
|
||||||
_ => InteractionModelError::Failed,
|
_ => InteractionModelError::Failed,
|
||||||
})?;
|
})?;
|
||||||
visible_text(&outcome.final_message)
|
visible_text(&outcome.final_message)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod session;
|
|||||||
pub mod tool_loop;
|
pub mod tool_loop;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod vision;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod behavior_tests;
|
mod behavior_tests;
|
||||||
@@ -50,6 +51,8 @@ mod script_delivery_tests;
|
|||||||
mod session_tests;
|
mod session_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tui_tests;
|
mod tui_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod vision_tests;
|
||||||
|
|
||||||
pub use backend::{
|
pub use backend::{
|
||||||
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
||||||
@@ -172,3 +175,11 @@ pub use types::{
|
|||||||
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent,
|
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent,
|
||||||
ProposedToolCall, ToolCallOutcome,
|
ProposedToolCall, ToolCallOutcome,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "live-grid")]
|
||||||
|
pub use vision::LibremetaverseSceneSource;
|
||||||
|
pub use vision::{
|
||||||
|
CameraPose, SceneEntity, SceneEntityKind, SceneSnapshot, SceneSource, SceneTriangle,
|
||||||
|
SnapshotCompleteness, VisionAugmentedResponder, VisionCapture, VisionControl, VisionError,
|
||||||
|
VisionFuture, VisionLimits, VisionObservation, VisionObservationKind, VisionService,
|
||||||
|
visual_question,
|
||||||
|
};
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ pub enum LlmError {
|
|||||||
HttpStatus(u16),
|
HttpStatus(u16),
|
||||||
MalformedJson,
|
MalformedJson,
|
||||||
UnsupportedResponse,
|
UnsupportedResponse,
|
||||||
|
MultimodalUnsupported,
|
||||||
DuplicateToolCallId,
|
DuplicateToolCallId,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +321,9 @@ impl fmt::Display for LlmError {
|
|||||||
Self::HttpStatus(status) => write!(formatter, "LLM endpoint returned HTTP {status}"),
|
Self::HttpStatus(status) => write!(formatter, "LLM endpoint returned HTTP {status}"),
|
||||||
Self::MalformedJson => formatter.write_str("LLM endpoint returned malformed JSON"),
|
Self::MalformedJson => formatter.write_str("LLM endpoint returned malformed JSON"),
|
||||||
Self::UnsupportedResponse => formatter.write_str("unsupported LLM response shape"),
|
Self::UnsupportedResponse => formatter.write_str("unsupported LLM response shape"),
|
||||||
|
Self::MultimodalUnsupported => {
|
||||||
|
formatter.write_str("LLM endpoint does not support image input")
|
||||||
|
}
|
||||||
Self::DuplicateToolCallId => {
|
Self::DuplicateToolCallId => {
|
||||||
formatter.write_str("LLM response repeated a tool-call ID")
|
formatter.write_str("LLM response repeated a tool-call ID")
|
||||||
}
|
}
|
||||||
@@ -390,6 +394,13 @@ impl LlmClient {
|
|||||||
for tool in tools {
|
for tool in tools {
|
||||||
tool.validate()?;
|
tool.validate()?;
|
||||||
}
|
}
|
||||||
|
let has_image = messages.iter().any(|message| {
|
||||||
|
message
|
||||||
|
.content
|
||||||
|
.as_slice()
|
||||||
|
.iter()
|
||||||
|
.any(|part| matches!(part, ContentPart::Image { .. }))
|
||||||
|
});
|
||||||
let body = request_body(messages, tools)?;
|
let body = request_body(messages, tools)?;
|
||||||
if body.len() > self.limits.max_prompt_bytes {
|
if body.len() > self.limits.max_prompt_bytes {
|
||||||
return Err(LlmError::PromptTooLarge);
|
return Err(LlmError::PromptTooLarge);
|
||||||
@@ -411,9 +422,14 @@ impl LlmClient {
|
|||||||
drop(permit);
|
drop(permit);
|
||||||
result
|
result
|
||||||
};
|
};
|
||||||
tokio::time::timeout(self.limits.total_timeout, operation)
|
let result = tokio::time::timeout(self.limits.total_timeout, operation)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| LlmError::Timeout)?
|
.map_err(|_| LlmError::Timeout)?;
|
||||||
|
if has_image && matches!(result, Err(LlmError::HttpStatus(400 | 415 | 422))) {
|
||||||
|
Err(LlmError::MultimodalUnsupported)
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn complete_with_retries(
|
async fn complete_with_retries(
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ async fn run_live(
|
|||||||
config.limits.observable_queue,
|
config.limits.observable_queue,
|
||||||
)?
|
)?
|
||||||
.start();
|
.start();
|
||||||
|
live.vision.set_generation(handle.status().generation);
|
||||||
|
|
||||||
if run_once {
|
if run_once {
|
||||||
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
||||||
@@ -232,19 +233,21 @@ async fn run_live(
|
|||||||
Some(event @ SessionObservation::Transition { status, .. })
|
Some(event @ SessionObservation::Transition { status, .. })
|
||||||
if status.agent_ready =>
|
if status.agent_ready =>
|
||||||
{
|
{
|
||||||
|
live.vision.set_generation(status.generation);
|
||||||
record_session_observation(&live.observability, &event);
|
record_session_observation(&live.observability, &event);
|
||||||
return Ok::<(), CliError>(());
|
return Ok::<(), CliError>(());
|
||||||
}
|
}
|
||||||
Some(
|
Some(
|
||||||
event @ SessionObservation::Transition {
|
event @ SessionObservation::Transition {
|
||||||
status:
|
status:
|
||||||
metacrate_grid_agent::SessionStatus {
|
status @ metacrate_grid_agent::SessionStatus {
|
||||||
state: SessionState::AuthenticationBlocked,
|
state: SessionState::AuthenticationBlocked,
|
||||||
..
|
..
|
||||||
},
|
},
|
||||||
..
|
..
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
live.vision.set_generation(status.generation);
|
||||||
record_session_observation(&live.observability, &event);
|
record_session_observation(&live.observability, &event);
|
||||||
return Err(CliError(
|
return Err(CliError(
|
||||||
"grid authentication/configuration requires operator action".into(),
|
"grid authentication/configuration requires operator action".into(),
|
||||||
@@ -292,6 +295,7 @@ async fn run_live(
|
|||||||
)?;
|
)?;
|
||||||
control_target.attach_observability(live.observability.clone());
|
control_target.attach_observability(live.observability.clone());
|
||||||
control_target.attach_build_control(live.build_control.clone());
|
control_target.attach_build_control(live.build_control.clone());
|
||||||
|
control_target.attach_vision_control(live.vision.clone());
|
||||||
control_target.update_session(handle.status());
|
control_target.update_session(handle.status());
|
||||||
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
|
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
|
||||||
let (control_plane, integrated_client, control_server) = match config.mode {
|
let (control_plane, integrated_client, control_server) = match config.mode {
|
||||||
@@ -360,6 +364,7 @@ async fn run_live(
|
|||||||
let Some(event) = event else { break; };
|
let Some(event) = event else { break; };
|
||||||
record_session_observation(&live.observability, &event);
|
record_session_observation(&live.observability, &event);
|
||||||
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
||||||
|
live.vision.set_generation(status.generation);
|
||||||
control_target.update_session(status);
|
control_target.update_session(status);
|
||||||
live.landmark_roaming
|
live.landmark_roaming
|
||||||
.update_pause(|pause| pause.degraded = !status.agent_ready);
|
.update_pause(|pause| pause.degraded = !status.agent_ready);
|
||||||
@@ -492,6 +497,8 @@ struct LiveInteractions {
|
|||||||
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
|
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
|
||||||
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
|
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
|
||||||
build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
|
build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
|
||||||
|
vision:
|
||||||
|
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
@@ -504,12 +511,13 @@ fn start_live_interactions(
|
|||||||
AuthorizedBackendRouter, AuthorizedToolBackend, BehaviorBackend, BehaviorController,
|
AuthorizedBackendRouter, AuthorizedToolBackend, BehaviorBackend, BehaviorController,
|
||||||
BuildLimits, BuildService, BuildToolBackend, ConversationStore, InteractionCoordinator,
|
BuildLimits, BuildService, BuildToolBackend, ConversationStore, InteractionCoordinator,
|
||||||
LandmarkLimits, LandmarkService, LandmarkToolBackend, LibremetaverseBuildGrid,
|
LandmarkLimits, LandmarkService, LandmarkToolBackend, LibremetaverseBuildGrid,
|
||||||
LibremetaverseLandmarkGrid, LibremetaverseLandmarkIntake, LibremetaverseScriptInventory,
|
LibremetaverseLandmarkGrid, LibremetaverseLandmarkIntake, LibremetaverseSceneSource,
|
||||||
LlmClient, LlmTransportLimits, MemoryPolicyAudit, Observability, ObservabilityLimits,
|
LibremetaverseScriptInventory, LlmClient, LlmTransportLimits, MemoryPolicyAudit,
|
||||||
PerceptionBackend, PolicyAuditSink, PolicyGateway, PolicyLimits, PolicyLlmResponder,
|
Observability, ObservabilityLimits, PerceptionBackend, PolicyAuditSink, PolicyGateway,
|
||||||
ScriptDeliveryBackend, ScriptDeliverySettings, SystemRoamingRandom, ToolLoopLimits,
|
PolicyLimits, PolicyLlmResponder, ScriptDeliveryBackend, ScriptDeliverySettings,
|
||||||
UnifiedPolicyAudit, behavior_policy_tools, build_policy_tools, landmark_policy_tools,
|
SystemRoamingRandom, ToolLoopLimits, UnifiedPolicyAudit, VisionAugmentedResponder,
|
||||||
perception_policy_tools, script_delivery_policy_tool,
|
VisionLimits, VisionService, behavior_policy_tools, build_policy_tools,
|
||||||
|
landmark_policy_tools, perception_policy_tools, script_delivery_policy_tool,
|
||||||
};
|
};
|
||||||
|
|
||||||
let transport_limits = LlmTransportLimits {
|
let transport_limits = LlmTransportLimits {
|
||||||
@@ -635,6 +643,12 @@ fn start_live_interactions(
|
|||||||
loop_limits,
|
loop_limits,
|
||||||
now,
|
now,
|
||||||
)?);
|
)?);
|
||||||
|
let vision_limits = VisionLimits::default();
|
||||||
|
let vision = Arc::new(VisionService::new(
|
||||||
|
Arc::new(LibremetaverseSceneSource::new(owner, vision_limits)),
|
||||||
|
vision_limits,
|
||||||
|
)?);
|
||||||
|
let responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder));
|
||||||
let sink = Arc::new(owner.interaction_sink());
|
let sink = Arc::new(owner.interaction_sink());
|
||||||
let pacer: Arc<dyn metacrate_grid_agent::ResponsePacer> = Arc::new(behavior_ingress);
|
let pacer: Arc<dyn metacrate_grid_agent::ResponsePacer> = Arc::new(behavior_ingress);
|
||||||
let interaction = InteractionCoordinator::new(
|
let interaction = InteractionCoordinator::new(
|
||||||
@@ -662,6 +676,7 @@ fn start_live_interactions(
|
|||||||
_landmark_intake: landmark_intake,
|
_landmark_intake: landmark_intake,
|
||||||
landmark_roaming,
|
landmark_roaming,
|
||||||
build_control,
|
build_control,
|
||||||
|
vision,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -717,6 +717,14 @@ fn render_overview(s: &OperatorSnapshot, out: &mut Vec<String>) {
|
|||||||
v.build_orphan_ids.len()
|
v.build_orphan_ids.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if v.active_visual_capture.is_some() || v.visual_image_sha256.is_some() {
|
||||||
|
out.push(format!(
|
||||||
|
"vision={} progress={} last-image-sha256={}",
|
||||||
|
v.active_visual_capture.as_deref().unwrap_or("idle"),
|
||||||
|
v.visual_progress.as_deref().unwrap_or("none"),
|
||||||
|
v.visual_image_sha256.as_deref().unwrap_or("none")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn render_queues(s: &OperatorSnapshot, out: &mut Vec<String>) {
|
fn render_queues(s: &OperatorSnapshot, out: &mut Vec<String>) {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ impl TuiTransport for FakeTransport {
|
|||||||
active_build_transaction: Some("build-7".into()),
|
active_build_transaction: Some("build-7".into()),
|
||||||
build_progress: Some("configured".into()),
|
build_progress: Some("configured".into()),
|
||||||
build_orphan_ids: Vec::new(),
|
build_orphan_ids: Vec::new(),
|
||||||
|
active_visual_capture: None,
|
||||||
|
visual_progress: None,
|
||||||
|
visual_image_sha256: None,
|
||||||
}),
|
}),
|
||||||
ControlRequest::ListSessions { .. } => ControlPayload::Sessions(crate::Page {
|
ControlRequest::ListSessions { .. } => ControlPayload::Sessions(crate::Page {
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
|
|||||||
1206
crates/metacrate-grid-agent/src/vision.rs
Normal file
1206
crates/metacrate-grid-agent/src/vision.rs
Normal file
File diff suppressed because it is too large
Load Diff
376
crates/metacrate-grid-agent/src/vision_tests.rs
Normal file
376
crates/metacrate-grid-agent/src/vision_tests.rs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
use crate::vision::*;
|
||||||
|
use crate::{
|
||||||
|
BoundedText, CompletionMessage, InteractionChannel, InteractionIntent, InteractionModelError,
|
||||||
|
InteractionOrigin, InteractionResponder, MessageRole, ResponderFuture, ResponseRequest,
|
||||||
|
VisibleResponse,
|
||||||
|
};
|
||||||
|
use libremetaverse_types::{
|
||||||
|
UUID,
|
||||||
|
compat::{CancellationToken, CancellationTokenSource},
|
||||||
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
struct FakeScene {
|
||||||
|
scene: SceneSnapshot,
|
||||||
|
pending: Mutex<bool>,
|
||||||
|
}
|
||||||
|
impl SceneSource for FakeScene {
|
||||||
|
fn capture_scene(
|
||||||
|
&self,
|
||||||
|
_: u64,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> VisionFuture<'_, SceneSnapshot> {
|
||||||
|
Box::pin(async move {
|
||||||
|
if *self.pending.lock().unwrap() {
|
||||||
|
tokio::select! {()=cancellation.cancelled()=>Err(VisionError::Cancelled),()=std::future::pending()=>unreachable!()}
|
||||||
|
} else {
|
||||||
|
Ok(self.scene.clone())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn uuid(value: u32) -> UUID {
|
||||||
|
UUID::new_with_string(format!("00000000-0000-4000-8000-{value:012}")).unwrap()
|
||||||
|
}
|
||||||
|
fn triangle(z: f32, color: [u8; 4]) -> SceneTriangle {
|
||||||
|
SceneTriangle {
|
||||||
|
vertices: [[-1.0, -1.0, z], [1.0, -1.0, z], [0.0, 1.0, z]],
|
||||||
|
color_srgb: color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn scene() -> SceneSnapshot {
|
||||||
|
SceneSnapshot {
|
||||||
|
generation: 7,
|
||||||
|
observed_unix_millis: 1_700_000_000_000,
|
||||||
|
region_id: uuid(1),
|
||||||
|
region_name: "Fixture Region".into(),
|
||||||
|
camera: CameraPose {
|
||||||
|
position: [0.0, 0.0, 0.0],
|
||||||
|
forward: [0.0, 0.0, 1.0],
|
||||||
|
up: [0.0, 1.0, 0.0],
|
||||||
|
vertical_fov_degrees: 60.0,
|
||||||
|
},
|
||||||
|
entities: vec![
|
||||||
|
SceneEntity {
|
||||||
|
id: uuid(3),
|
||||||
|
kind: SceneEntityKind::Resident,
|
||||||
|
display_name: "Private Resident".into(),
|
||||||
|
triangles: vec![triangle(4.0, [255, 0, 0, 255])],
|
||||||
|
texture_available: false,
|
||||||
|
},
|
||||||
|
SceneEntity {
|
||||||
|
id: uuid(2),
|
||||||
|
kind: SceneEntityKind::Object,
|
||||||
|
display_name: "Near".into(),
|
||||||
|
triangles: vec![triangle(2.0, [0, 255, 0, 255])],
|
||||||
|
texture_available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
completeness: SnapshotCompleteness {
|
||||||
|
textures_missing: 1,
|
||||||
|
terrain_available: false,
|
||||||
|
..SnapshotCompleteness::default()
|
||||||
|
},
|
||||||
|
texture_fetches: 1,
|
||||||
|
texture_bytes: 128,
|
||||||
|
decoded_texture_pixels: 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: scene(),
|
||||||
|
pending: Mutex::new(false),
|
||||||
|
});
|
||||||
|
let service = VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
service.set_generation(7);
|
||||||
|
let first = service
|
||||||
|
.capture("one", 7, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second = service
|
||||||
|
.capture("two", 7, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.png, second.png);
|
||||||
|
assert_eq!(first.image_sha256, second.image_sha256);
|
||||||
|
assert_eq!(
|
||||||
|
first.image_sha256,
|
||||||
|
"e943b14142da05fcc1471ee0b639ffc1c11c74bfde83432ad3a26f994e4e665a"
|
||||||
|
);
|
||||||
|
assert!(first.summary.contains("privacy-marked residents"));
|
||||||
|
assert!(!first.summary.contains("Private Resident"));
|
||||||
|
assert!(first.summary.contains("textures_missing=1"));
|
||||||
|
assert!(first.data_url.starts_with("data:image/png;base64,"));
|
||||||
|
let decoder = png::Decoder::new(first.png.as_slice());
|
||||||
|
let mut reader = decoder.read_info().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reader.info().srgb,
|
||||||
|
Some(png::SrgbRenderingIntent::Perceptual)
|
||||||
|
);
|
||||||
|
let mut pixels = vec![0; reader.output_buffer_size()];
|
||||||
|
let info = reader.next_frame(&mut pixels).unwrap();
|
||||||
|
let center = ((info.height as usize / 2) * info.width as usize + info.width as usize / 2) * 4;
|
||||||
|
assert_eq!(&pixels[center..center + 4], &[0, 255, 0, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_camera_huge_scene_stale_generation_and_size_limit_fail_closed() {
|
||||||
|
let mut bad = scene();
|
||||||
|
bad.camera.forward = [0.0, 0.0, 0.0];
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: bad,
|
||||||
|
pending: Mutex::new(false),
|
||||||
|
});
|
||||||
|
let service = VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
service.set_generation(7);
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.capture("bad", 7, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
VisionError::InvalidScene
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.capture("stale", 6, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
VisionError::StaleGeneration
|
||||||
|
);
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: scene(),
|
||||||
|
pending: Mutex::new(false),
|
||||||
|
});
|
||||||
|
let service = VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
max_png_bytes: 4096,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
service.set_generation(7);
|
||||||
|
let result = service
|
||||||
|
.capture("size", 7, CancellationToken::default())
|
||||||
|
.await;
|
||||||
|
assert!(matches!(result, Ok(_) | Err(VisionError::ResourceLimit)));
|
||||||
|
|
||||||
|
let mut excessive_texture_work = scene();
|
||||||
|
excessive_texture_work.texture_fetches = VisionLimits::default().max_texture_fetches + 1;
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: excessive_texture_work,
|
||||||
|
pending: Mutex::new(false),
|
||||||
|
});
|
||||||
|
let service = VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
service.set_generation(7);
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.capture("texture-budget", 7, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
VisionError::ResourceLimit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_and_region_change_interrupt_nonblocking_capture() {
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: scene(),
|
||||||
|
pending: Mutex::new(true),
|
||||||
|
});
|
||||||
|
let service = Arc::new(
|
||||||
|
VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
service.set_generation(7);
|
||||||
|
let cancellation = CancellationTokenSource::new();
|
||||||
|
cancellation.cancel();
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.capture("cancel", 7, cancellation.token())
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
VisionError::Cancelled
|
||||||
|
);
|
||||||
|
let service2 = service.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
service2
|
||||||
|
.capture("region", 7, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
service.set_generation(8);
|
||||||
|
assert!(matches!(
|
||||||
|
task.await.unwrap(),
|
||||||
|
Err(VisionError::Superseded | VisionError::Cancelled)
|
||||||
|
));
|
||||||
|
|
||||||
|
service.set_generation(9);
|
||||||
|
let first_service = service.clone();
|
||||||
|
let first = tokio::spawn(async move {
|
||||||
|
first_service
|
||||||
|
.capture("first", 9, CancellationToken::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
let second_cancel = CancellationTokenSource::new();
|
||||||
|
let second_service = service.clone();
|
||||||
|
let second_token = second_cancel.token();
|
||||||
|
let second =
|
||||||
|
tokio::spawn(async move { second_service.capture("second", 9, second_token).await });
|
||||||
|
assert_eq!(
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), first)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_err(),
|
||||||
|
VisionError::Superseded
|
||||||
|
);
|
||||||
|
second_cancel.cancel();
|
||||||
|
assert_eq!(second.await.unwrap().unwrap_err(), VisionError::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_visual_intent_is_narrow() {
|
||||||
|
assert!(visual_question("What do you see in front of you?"));
|
||||||
|
assert!(visual_question("Please look at this viewport"));
|
||||||
|
assert!(!visual_question("hello there"));
|
||||||
|
assert!(!visual_question("build a cube"));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeResponder {
|
||||||
|
parts: Mutex<usize>,
|
||||||
|
unsupported: bool,
|
||||||
|
}
|
||||||
|
impl InteractionResponder for FakeResponder {
|
||||||
|
fn respond(&self, request: ResponseRequest, _: CancellationToken) -> ResponderFuture<'_> {
|
||||||
|
Box::pin(async move {
|
||||||
|
*self.parts.lock().unwrap() = request.messages.last().unwrap().content.len();
|
||||||
|
if self.unsupported {
|
||||||
|
Err(InteractionModelError::MultimodalUnsupported)
|
||||||
|
} else {
|
||||||
|
VisibleResponse::new("seen").map_err(|_| InteractionModelError::Failed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn request(text: &str) -> ResponseRequest {
|
||||||
|
ResponseRequest {
|
||||||
|
delivery_id: BoundedText::new("id", "delivery").unwrap(),
|
||||||
|
sender_id: uuid(9),
|
||||||
|
session_id: BoundedText::new("session", "session").unwrap(),
|
||||||
|
channel: InteractionChannel::DirectIm,
|
||||||
|
origin: InteractionOrigin::AuthorizedIm,
|
||||||
|
intent: InteractionIntent::Informational,
|
||||||
|
messages: vec![CompletionMessage::text(MessageRole::Avatar, text).unwrap()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn responder_adds_generic_image_payload_and_returns_summary_on_capability_rejection() {
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: scene(),
|
||||||
|
pending: Mutex::new(false),
|
||||||
|
});
|
||||||
|
let vision = Arc::new(
|
||||||
|
VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
vision.set_generation(7);
|
||||||
|
let endpoint = Arc::new(FakeResponder {
|
||||||
|
parts: Mutex::new(0),
|
||||||
|
unsupported: true,
|
||||||
|
});
|
||||||
|
let responder = VisionAugmentedResponder::new(vision, endpoint.clone());
|
||||||
|
let response = responder
|
||||||
|
.respond(request("What do you see?"), CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(response.as_str().contains("does not support visual input"));
|
||||||
|
assert!(response.as_str().contains("Synthetic viewport"));
|
||||||
|
assert_eq!(*endpoint.parts.lock().unwrap(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn responder_never_captures_for_ordinary_text() {
|
||||||
|
let source = Arc::new(FakeScene {
|
||||||
|
scene: scene(),
|
||||||
|
pending: Mutex::new(false),
|
||||||
|
});
|
||||||
|
let vision = Arc::new(
|
||||||
|
VisionService::new(
|
||||||
|
source,
|
||||||
|
VisionLimits {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
minimum_interval: std::time::Duration::ZERO,
|
||||||
|
..VisionLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
vision.set_generation(7);
|
||||||
|
let endpoint = Arc::new(FakeResponder {
|
||||||
|
parts: Mutex::new(0),
|
||||||
|
unsupported: false,
|
||||||
|
});
|
||||||
|
let responder = VisionAugmentedResponder::new(vision.clone(), endpoint.clone());
|
||||||
|
assert_eq!(
|
||||||
|
responder
|
||||||
|
.respond(request("hello"), CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"seen"
|
||||||
|
);
|
||||||
|
assert_eq!(*endpoint.parts.lock().unwrap(), 1);
|
||||||
|
assert!(vision.observations().is_empty());
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@ use std::collections::BTreeSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const ALLOWED_DEPENDENCIES: [&str; 13] = [
|
const ALLOWED_DEPENDENCIES: [&str; 17] = [
|
||||||
|
"base64",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
|
"libremetaverse-imaging",
|
||||||
|
"libremetaverse-rendering-simple",
|
||||||
"metacrate-lsl-tools",
|
"metacrate-lsl-tools",
|
||||||
|
"png",
|
||||||
"libremetaverse-types",
|
"libremetaverse-types",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -48,10 +52,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
||||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
let mut files = Vec::with_capacity(34);
|
let mut files = Vec::with_capacity(36);
|
||||||
collect_rust_files(&source, &mut files);
|
collect_rust_files(&source, &mut files);
|
||||||
assert!(
|
assert!(
|
||||||
files.len() <= 34,
|
files.len() <= 36,
|
||||||
"source-file count needs a reviewed bound update"
|
"source-file count needs a reviewed bound update"
|
||||||
);
|
);
|
||||||
for path in files {
|
for path in files {
|
||||||
@@ -82,6 +86,27 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vision_path_selects_only_the_pure_rust_renderer_and_codec() {
|
||||||
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let manifest = fs::read_to_string(root.join("Cargo.toml")).expect("agent manifest");
|
||||||
|
assert!(manifest.contains("default-features = false, features = [\"rust-j2k\"]"));
|
||||||
|
for forbidden in ["imaging-skia", "openjpeg", "skia-safe"] {
|
||||||
|
assert!(!manifest.contains(forbidden), "vision enables {forbidden}");
|
||||||
|
}
|
||||||
|
let renderer = fs::read_to_string(root.join("../libremetaverse-rendering-simple/Cargo.toml"))
|
||||||
|
.expect("simple renderer manifest");
|
||||||
|
assert!(!renderer.contains("imaging-skia"));
|
||||||
|
assert!(!renderer.contains("openjpeg"));
|
||||||
|
let vision = fs::read_to_string(root.join("src/vision.rs")).expect("vision source");
|
||||||
|
for forbidden in ["screenshot crate", "gpu adapter", "std::process"] {
|
||||||
|
assert!(
|
||||||
|
!vision.to_ascii_lowercase().contains(forbidden),
|
||||||
|
"vision must remain synthetic and pure Rust: {forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compatibility_crates_never_depend_on_or_reexport_metacrate_crates() {
|
fn compatibility_crates_never_depend_on_or_reexport_metacrate_crates() {
|
||||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
|||||||
@@ -503,6 +503,45 @@ async fn retry_classification_retry_after_timeout_and_cancellation_are_bounded()
|
|||||||
assert_eq!(request.await.expect_err("cancelled"), LlmError::Cancelled);
|
assert_eq!(request.await.expect_err("cancelled"), LlmError::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multimodal_capability_rejection_is_typed_and_never_retried() {
|
||||||
|
let mut server = fake_server(vec![ResponsePlan::status(415)]).await;
|
||||||
|
let mut retry_limits = limits();
|
||||||
|
retry_limits.max_retries = 2;
|
||||||
|
let image = CompletionMessage {
|
||||||
|
role: MessageRole::Avatar,
|
||||||
|
content: metacrate_grid_agent::BoundedVec::try_from_vec(
|
||||||
|
"message.content",
|
||||||
|
vec![ContentPart::Image {
|
||||||
|
url: metacrate_grid_agent::BoundedText::new(
|
||||||
|
"image.url",
|
||||||
|
"data:image/png;base64,iVBORw0KGgo=",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
detail: ImageDetail::Low,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
tool_call_id: None,
|
||||||
|
proposed_calls: metacrate_grid_agent::BoundedVec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
client(&server.url, retry_limits)
|
||||||
|
.complete(&[image], &[], &CancellationToken::default())
|
||||||
|
.await
|
||||||
|
.expect_err("multimodal rejection"),
|
||||||
|
LlmError::MultimodalUnsupported
|
||||||
|
);
|
||||||
|
server.requests.recv().await.expect("one image request");
|
||||||
|
assert!(
|
||||||
|
!matches!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), server.requests.recv()).await,
|
||||||
|
Ok(Some(_))
|
||||||
|
),
|
||||||
|
"capability rejection must not resend the large image"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn concurrent_slow_sessions_respect_the_shared_semaphore() {
|
async fn concurrent_slow_sessions_respect_the_shared_semaphore() {
|
||||||
let plans = (0..6)
|
let plans = (0..6)
|
||||||
|
|||||||
Reference in New Issue
Block a user