feat(grid-agent): add structured observability and replay (#127)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m43s
CI / required (push) Failing after 2m42s

This commit is contained in:
2026-08-18 05:28:47 +00:00
parent 962d17257d
commit 8658c0407e
14 changed files with 3123 additions and 39 deletions

View File

@@ -7,7 +7,7 @@ use std::path::PathBuf;
#[cfg(feature = "live-grid")]
use std::sync::Arc;
#[cfg(feature = "live-grid")]
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const MAX_ARGUMENTS: usize = 8;
@@ -184,22 +184,28 @@ async fn run_live(
let readiness = tokio::time::timeout(config.timeouts.startup, async {
loop {
match handle.next_observation().await {
Some(SessionObservation::Transition { status, .. }) if status.agent_ready => {
Some(event @ SessionObservation::Transition { status, .. })
if status.agent_ready =>
{
record_session_observation(&live.observability, &event);
return Ok::<(), CliError>(());
}
Some(SessionObservation::Transition {
status:
metacrate_grid_agent::SessionStatus {
state: SessionState::AuthenticationBlocked,
..
},
..
}) => {
Some(
event @ SessionObservation::Transition {
status:
metacrate_grid_agent::SessionStatus {
state: SessionState::AuthenticationBlocked,
..
},
..
},
) => {
record_session_observation(&live.observability, &event);
return Err(CliError(
"grid authentication/configuration requires operator action".into(),
));
}
Some(_) => {}
Some(event) => record_session_observation(&live.observability, &event),
None => {
return Err(CliError(
"session supervisor stopped before readiness".into(),
@@ -239,6 +245,7 @@ async fn run_live(
live.behavior.ingress(),
config.limits.control_queue,
)?;
control_target.attach_observability(live.observability.clone());
control_target.update_session(handle.status());
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
let (control_plane, _integrated_client, control_server) = match config.mode {
@@ -292,6 +299,7 @@ async fn run_live(
}
event = handle.next_observation() => {
let Some(event) = event else { break; };
record_session_observation(&live.observability, &event);
if let SessionObservation::Transition { status, reason, retry_in } = event {
control_target.update_session(status);
control_plane.publish(ControlEventKind::StateChanged {
@@ -309,14 +317,17 @@ async fn run_live(
}
event = live.interaction.next_observation() => {
let Some(event) = event else { break; };
record_interaction_observation(&live.observability, &event);
println!("grid interaction event={event:?}");
}
event = live.perception_observations.recv() => {
let Some(event) = event else { break; };
record_perception_observation(&live.observability, &event);
println!("grid perception event={event:?}");
}
event = live.behavior.next_observation() => {
let Some(event) = event else { break; };
record_behavior_observation(&live.observability, &event);
if let BehaviorObservation::Transition { to, .. } = &event {
control_target.update_behavior(*to);
control_plane.publish(ControlEventKind::StateChanged {
@@ -358,6 +369,14 @@ async fn run_live(
}
}
}
let shutdown_event = metacrate_grid_agent::EventDraft::new(
metacrate_grid_agent::EventFamily::Shutdown,
metacrate_grid_agent::EventSeverity::Info,
"service",
metacrate_grid_agent::EventOrigin::Service,
)?
.result_code("requested")?;
let _ = live.observability.record(shutdown_event);
control_target.mark_stopping();
let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await;
@@ -391,6 +410,7 @@ struct LiveInteractions {
conversations: Arc<metacrate_grid_agent::ConversationStore>,
policy: Arc<metacrate_grid_agent::PolicyGateway>,
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
observability: Arc<metacrate_grid_agent::Observability>,
}
#[cfg(feature = "live-grid")]
@@ -402,8 +422,9 @@ fn start_live_interactions(
use metacrate_grid_agent::{
AuthorizedBackendRouter, AuthorizedToolBackend, BehaviorBackend, BehaviorController,
ConversationStore, InteractionCoordinator, LlmClient, LlmTransportLimits,
MemoryPolicyAudit, PerceptionBackend, PolicyGateway, PolicyLimits, PolicyLlmResponder,
ToolLoopLimits, behavior_policy_tools, perception_policy_tools,
MemoryPolicyAudit, Observability, ObservabilityLimits, PerceptionBackend, PolicyAuditSink,
PolicyGateway, PolicyLimits, PolicyLlmResponder, ToolLoopLimits, UnifiedPolicyAudit,
behavior_policy_tools, perception_policy_tools,
};
let transport_limits = LlmTransportLimits {
@@ -435,6 +456,16 @@ fn start_live_interactions(
let behavior_ingress = behavior.ingress();
let client = Arc::new(LlmClient::new(config.llm.clone(), transport_limits)?);
let audit = Arc::new(MemoryPolicyAudit::new(config.limits.observable_queue)?);
let observability = Observability::memory(ObservabilityLimits {
ring_events: config.limits.observable_queue,
subscriber_queue: config.limits.observable_queue.min(512),
journal_queue: config.limits.observable_queue,
..ObservabilityLimits::default()
})?;
let audit_sink: Arc<dyn PolicyAuditSink> = Arc::new(UnifiedPolicyAudit::new(
audit.clone(),
observability.clone(),
));
let mut tools = perception_policy_tools()?;
tools.extend(behavior_policy_tools(&config.behavior)?);
let routes = tools
@@ -453,7 +484,7 @@ fn start_live_interactions(
config.authorized_avatar_uuids.clone(),
tools,
PolicyLimits::default(),
audit.clone(),
audit_sink,
)?);
let loop_limits = ToolLoopLimits {
max_tool_calls_per_turn: config.limits.max_tool_calls,
@@ -500,5 +531,497 @@ fn start_live_interactions(
conversations: conversation,
policy: gateway,
audit,
observability,
})
}
#[cfg(feature = "live-grid")]
fn record_session_observation(
observer: &metacrate_grid_agent::Observability,
observation: &metacrate_grid_agent::SessionObservation,
) {
use metacrate_grid_agent::{
EventDraft, EventFamily, EventOrigin, EventSeverity, SessionObservation,
};
let SessionObservation::Transition {
status,
reason,
retry_in,
} = observation
else {
return;
};
observer.metrics().set_ready(status.agent_ready);
if matches!(
reason,
metacrate_grid_agent::SessionReason::TransientTransport
| metacrate_grid_agent::SessionReason::Maintenance
| metacrate_grid_agent::SessionReason::Kicked
| metacrate_grid_agent::SessionReason::SimulatorDisconnected
| metacrate_grid_agent::SessionReason::ServerFailure
| metacrate_grid_agent::SessionReason::OperatorForceReconnect
) {
observer.metrics().record_reconnect();
}
let event = EventDraft::new(
EventFamily::LifecycleTransition,
if status.agent_ready {
EventSeverity::Info
} else {
EventSeverity::Warning
},
"session",
EventOrigin::Grid,
)
.and_then(|event| event.reason_code(session_reason_code(*reason)))
.and_then(|event| {
event
.retry_count(status.consecutive_failures)
.code_field("to", status.state.as_str())
})
.and_then(|event| event.field("generation", serde_json::Value::from(status.generation)))
.and_then(|event| event.field("ready", serde_json::Value::from(status.agent_ready)))
.and_then(|event| {
event.field(
"transport_connected",
serde_json::Value::from(status.transport_connected),
)
})
.and_then(|event| {
event.field(
"retry_millis",
serde_json::Value::from(retry_in.map_or(0, |duration| {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
})),
)
});
if let Ok(event) = event {
let _ = observer.record(event);
}
}
#[cfg(feature = "live-grid")]
#[allow(clippy::too_many_lines)]
fn record_interaction_observation(
observer: &metacrate_grid_agent::Observability,
observation: &metacrate_grid_agent::InteractionObservation,
) {
use metacrate_grid_agent::{
CorrelationIds, EventDraft, EventFamily, EventOrigin, EventSeverity, InferenceOutcome,
InteractionObservation, MemoryReason, OutcomeCode,
};
let event = match observation {
InteractionObservation::SessionMemory { event } => {
let (family, state) = match event.reason {
MemoryReason::SessionCreated => (EventFamily::SessionCreated, "created"),
MemoryReason::PublicExpired
| MemoryReason::DirectImExpired
| MemoryReason::SessionLimitEviction
| MemoryReason::TotalByteEviction
| MemoryReason::OperatorDeleted
| MemoryReason::OperatorExpired => (EventFamily::SessionExpired, "expired"),
MemoryReason::TurnCompacted
| MemoryReason::ByteCompacted
| MemoryReason::ToolResultCompacted => {
(EventFamily::LifecycleTransition, "compacted")
}
MemoryReason::CorruptSnapshotQuarantined
| MemoryReason::UnsupportedSnapshotQuarantined
| MemoryReason::SnapshotTooLargeQuarantined
| MemoryReason::PermissionsNotVerified => {
(EventFamily::LifecycleTransition, "quarantined")
}
};
EventDraft::new(
family,
EventSeverity::Info,
"conversation",
EventOrigin::Service,
)
.and_then(|draft| {
draft.correlation(CorrelationIds {
avatar_id: event.avatar_id.map(|id| id.to_string()),
session_id: event.session_id.as_ref().map(|id| id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.and_then(|draft| draft.reason_code(memory_reason_code(event.reason)))
.and_then(|draft| draft.code_field("state", state))
}
InteractionObservation::Suppressed {
delivery_id,
reason,
} => EventDraft::new(
EventFamily::InboundMessage,
EventSeverity::Info,
"interaction",
EventOrigin::Grid,
)
.and_then(|event| {
event.correlation(CorrelationIds {
request_id: Some(delivery_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.and_then(|event| event.result_code("suppressed"))
.and_then(|event| event.reason_code(suppression_reason_code(*reason)))
.and_then(|event| event.redacted("message_content")),
InteractionObservation::IntentRouted {
delivery_id,
session_id,
origin,
intent,
} => EventDraft::new(
EventFamily::InboundMessage,
EventSeverity::Info,
"interaction",
EventOrigin::Grid,
)
.and_then(|event| {
event.correlation(CorrelationIds {
session_id: Some(session_id.as_str().to_owned()),
request_id: Some(delivery_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.and_then(|event| event.result_code("routed"))
.and_then(|event| event.code_field("origin", interaction_origin_code(*origin)))
.and_then(|event| event.code_field("intent", interaction_intent_code(*intent)))
.and_then(|event| event.redacted("message_content")),
InteractionObservation::AttentionRequested {
delivery_id,
avatar_id,
} => EventDraft::new(
EventFamily::ModelActionSummary,
EventSeverity::Info,
"behavior",
EventOrigin::Service,
)
.and_then(|event| {
event.correlation(CorrelationIds {
avatar_id: Some(avatar_id.to_string()),
request_id: Some(delivery_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.and_then(|event| event.code_field("summary", "face_speaker")),
InteractionObservation::InferenceStarted {
delivery_id,
session_id,
prompt_messages,
} => EventDraft::new(
EventFamily::InferenceRequest,
EventSeverity::Info,
"llm",
EventOrigin::Service,
)
.and_then(|event| {
event.correlation(CorrelationIds {
session_id: Some(session_id.as_str().to_owned()),
request_id: Some(delivery_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.and_then(|event| event.field("prompt_messages", serde_json::Value::from(*prompt_messages)))
.and_then(|event| event.redacted("prompt_content")),
InteractionObservation::InferenceFinished {
delivery_id,
session_id,
outcome,
duration_millis,
} => {
observer
.metrics()
.record_inference_latency(Duration::from_millis(*duration_millis));
let (severity, result, metric) = match outcome {
InferenceOutcome::Completed => {
(EventSeverity::Info, "completed", OutcomeCode::Completed)
}
InferenceOutcome::Cancelled => {
(EventSeverity::Warning, "cancelled", OutcomeCode::Cancelled)
}
InferenceOutcome::TimedOut => {
(EventSeverity::Warning, "timed_out", OutcomeCode::Failed)
}
InferenceOutcome::Failed => (EventSeverity::Error, "failed", OutcomeCode::Failed),
InferenceOutcome::PolicyRejected => (
EventSeverity::Warning,
"policy_rejected",
OutcomeCode::Denied,
),
};
observer.metrics().record_inference_outcome(metric);
EventDraft::new(
EventFamily::InferenceResult,
severity,
"llm",
EventOrigin::Model,
)
.and_then(|event| {
event.correlation(CorrelationIds {
session_id: Some(session_id.as_str().to_owned()),
request_id: Some(delivery_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.map(|event| event.duration_millis(*duration_millis))
.and_then(|event| event.result_code(result))
.and_then(|event| event.redacted("response_content"))
}
InteractionObservation::Delivery {
delivery_id,
session_id,
channel,
outcome,
delivered_parts,
} => EventDraft::new(
EventFamily::OutboundMessage,
EventSeverity::Info,
"interaction",
EventOrigin::Service,
)
.and_then(|event| {
event.correlation(CorrelationIds {
session_id: Some(session_id.as_str().to_owned()),
request_id: Some(delivery_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.and_then(|event| event.result_code(delivery_outcome_code(*outcome)))
.and_then(|event| event.code_field("channel", interaction_channel_code(*channel)))
.and_then(|event| event.field("delivered_parts", serde_json::Value::from(*delivered_parts)))
.and_then(|event| event.redacted("message_content")),
};
if let Ok(event) = event {
let _ = observer.record(event);
}
}
#[cfg(feature = "live-grid")]
fn record_perception_observation(
observer: &metacrate_grid_agent::Observability,
observation: &metacrate_grid_agent::PerceptionObservation,
) {
use metacrate_grid_agent::{
CorrelationIds, EventDraft, EventFamily, EventOrigin, EventSeverity, PerceptionOutcome,
};
observer
.metrics()
.record_tool_latency(Duration::from_millis(observation.duration_millis));
let result = match observation.outcome {
PerceptionOutcome::Completed => "completed",
PerceptionOutcome::Rejected => "rejected",
};
let event = EventDraft::new(
EventFamily::ToolResult,
EventSeverity::Info,
"perception",
EventOrigin::Service,
)
.and_then(|event| {
event.correlation(CorrelationIds {
action_id: Some(observation.call_id.as_str().to_owned()),
..CorrelationIds::default()
})
})
.map(|event| event.duration_millis(observation.duration_millis))
.and_then(|event| event.result_code(result))
.and_then(|event| event.identifier_field("tool", observation.tool.as_str()))
.and_then(|event| {
event.field(
"result_bytes",
serde_json::Value::from(observation.result_bytes),
)
})
.and_then(|event| event.field("cache_hit", serde_json::Value::from(observation.cache_hit)))
.and_then(|event| event.redacted("tool_result"));
if let Ok(event) = event {
let _ = observer.record(event);
}
}
#[cfg(feature = "live-grid")]
fn record_behavior_observation(
observer: &metacrate_grid_agent::Observability,
observation: &metacrate_grid_agent::BehaviorObservation,
) {
use metacrate_grid_agent::{
BehaviorObservation, CorrelationIds, EventDraft, EventFamily, EventOrigin, EventSeverity,
};
let event = match observation {
BehaviorObservation::Transition { from, to, .. } => EventDraft::new(
EventFamily::BehaviorTransition,
EventSeverity::Info,
"behavior",
EventOrigin::Service,
)
.and_then(|event| event.code_field("from", behavior_mode_code(*from)))
.and_then(|event| event.code_field("to", behavior_mode_code(*to))),
BehaviorObservation::Action {
action_id,
generation,
action,
duration_millis,
outcome,
..
} => EventDraft::new(
EventFamily::ToolResult,
EventSeverity::Info,
"behavior",
EventOrigin::Service,
)
.and_then(|event| {
event.correlation(CorrelationIds {
action_id: Some(action_id.clone()),
..CorrelationIds::default()
})
})
.map(|event| event.duration_millis(*duration_millis))
.and_then(|event| event.result_code(behavior_outcome_code(*outcome)))
.and_then(|event| event.identifier_field("tool", action))
.and_then(|event| {
event.field(
"generation",
serde_json::Value::from(generation.unwrap_or(0)),
)
}),
};
if let Ok(event) = event {
let _ = observer.record(event);
}
}
#[cfg(feature = "live-grid")]
const fn session_reason_code(reason: metacrate_grid_agent::SessionReason) -> &'static str {
use metacrate_grid_agent::SessionReason;
match reason {
SessionReason::Startup => "startup",
SessionReason::LoginSucceeded => "login_succeeded",
SessionReason::AgentReady => "agent_ready",
SessionReason::ReadinessLost => "readiness_lost",
SessionReason::TransientTransport => "transient_transport",
SessionReason::Maintenance => "maintenance",
SessionReason::Kicked => "kicked",
SessionReason::SimulatorDisconnected => "simulator_disconnected",
SessionReason::ServerFailure => "server_failure",
SessionReason::InvalidCredentials => "invalid_credentials",
SessionReason::InvalidConfiguration => "invalid_configuration",
SessionReason::StableSessionReset => "stable_session_reset",
SessionReason::OperatorPause => "operator_pause",
SessionReason::OperatorResume => "operator_resume",
SessionReason::OperatorForceReconnect => "operator_force_reconnect",
SessionReason::OperatorLogout => "operator_logout",
SessionReason::ShutdownRequested => "shutdown_requested",
SessionReason::ShutdownComplete => "shutdown_complete",
}
}
#[cfg(feature = "live-grid")]
const fn memory_reason_code(reason: metacrate_grid_agent::MemoryReason) -> &'static str {
use metacrate_grid_agent::MemoryReason;
match reason {
MemoryReason::SessionCreated => "session_created",
MemoryReason::PublicExpired => "public_expired",
MemoryReason::DirectImExpired => "direct_im_expired",
MemoryReason::TurnCompacted => "turn_compacted",
MemoryReason::ByteCompacted => "byte_compacted",
MemoryReason::ToolResultCompacted => "tool_result_compacted",
MemoryReason::SessionLimitEviction => "session_limit_eviction",
MemoryReason::TotalByteEviction => "total_byte_eviction",
MemoryReason::OperatorDeleted => "operator_deleted",
MemoryReason::OperatorExpired => "operator_expired",
MemoryReason::CorruptSnapshotQuarantined => "corrupt_snapshot_quarantined",
MemoryReason::UnsupportedSnapshotQuarantined => "unsupported_snapshot_quarantined",
MemoryReason::SnapshotTooLargeQuarantined => "snapshot_too_large_quarantined",
MemoryReason::PermissionsNotVerified => "permissions_not_verified",
}
}
#[cfg(feature = "live-grid")]
const fn suppression_reason_code(reason: metacrate_grid_agent::SuppressionReason) -> &'static str {
use metacrate_grid_agent::SuppressionReason;
match reason {
SuppressionReason::SelfEcho => "self_echo",
SuppressionReason::Duplicate => "duplicate",
SuppressionReason::Muted => "muted",
SuppressionReason::UnsupportedSource => "unsupported_source",
SuppressionReason::UnsupportedDialog => "unsupported_dialog",
SuppressionReason::AmbientPublicChat => "ambient_public_chat",
SuppressionReason::SenderQueueFull => "sender_queue_full",
SuppressionReason::SenderLimit => "sender_limit",
SuppressionReason::Disconnected => "disconnected",
SuppressionReason::UnsafeResponse => "unsafe_response",
SuppressionReason::Cancelled => "cancelled",
}
}
#[cfg(feature = "live-grid")]
const fn interaction_origin_code(origin: metacrate_grid_agent::InteractionOrigin) -> &'static str {
use metacrate_grid_agent::InteractionOrigin;
match origin {
InteractionOrigin::Public => "public",
InteractionOrigin::UnprivilegedIm => "unprivileged_im",
InteractionOrigin::AuthorizedIm => "authorized_im",
}
}
#[cfg(feature = "live-grid")]
const fn interaction_intent_code(intent: metacrate_grid_agent::InteractionIntent) -> &'static str {
use metacrate_grid_agent::InteractionIntent;
match intent {
InteractionIntent::Informational => "informational",
InteractionIntent::PublicCommandDenied => "public_command_denied",
InteractionIntent::PolicyGatedCommand => "policy_gated_command",
InteractionIntent::PolicyGatedLslRequest => "policy_gated_lsl_request",
}
}
#[cfg(feature = "live-grid")]
const fn delivery_outcome_code(outcome: metacrate_grid_agent::DeliveryOutcome) -> &'static str {
use metacrate_grid_agent::DeliveryOutcome;
match outcome {
DeliveryOutcome::Succeeded => "succeeded",
DeliveryOutcome::Failed => "failed",
DeliveryOutcome::TimedOut => "timed_out",
DeliveryOutcome::PolicyDenied => "policy_denied",
}
}
#[cfg(feature = "live-grid")]
const fn interaction_channel_code(
channel: metacrate_grid_agent::InteractionChannel,
) -> &'static str {
use metacrate_grid_agent::InteractionChannel;
match channel {
InteractionChannel::PublicChat => "public_chat",
InteractionChannel::DirectIm => "direct_im",
}
}
#[cfg(feature = "live-grid")]
const fn behavior_mode_code(mode: metacrate_grid_agent::BehaviorMode) -> &'static str {
use metacrate_grid_agent::BehaviorMode;
match mode {
BehaviorMode::Offline => "offline",
BehaviorMode::Settling => "settling",
BehaviorMode::Available => "available",
BehaviorMode::Engaged => "engaged",
BehaviorMode::Executing => "executing",
BehaviorMode::Roaming => "roaming",
BehaviorMode::Paused => "paused",
BehaviorMode::Recovering => "recovering",
}
}
#[cfg(feature = "live-grid")]
const fn behavior_outcome_code(outcome: metacrate_grid_agent::BehaviorOutcome) -> &'static str {
use metacrate_grid_agent::BehaviorOutcome;
match outcome {
BehaviorOutcome::Completed => "completed",
BehaviorOutcome::Cancelled => "cancelled",
BehaviorOutcome::TimedOut => "timed_out",
BehaviorOutcome::Stuck => "stuck",
BehaviorOutcome::Rejected => "rejected",
BehaviorOutcome::Preempted => "preempted",
}
}