feat(grid-agent): add structured observability and replay (#127)
This commit is contained in:
@@ -57,3 +57,7 @@ and metadata-only operator controls are specified in
|
|||||||
The versioned integrated/TCP protocol, roles, framing, bounds, TLS remote-mode
|
The versioned integrated/TCP protocol, roles, framing, bounds, TLS remote-mode
|
||||||
requirements, events, and management methods are specified in
|
requirements, events, and management methods are specified in
|
||||||
[`../../docs/grid-agent-control-plane.md`](../../docs/grid-agent-control-plane.md).
|
[`../../docs/grid-agent-control-plane.md`](../../docs/grid-agent-control-plane.md).
|
||||||
|
The unified event schema, pseudonymous correlations, bounded JSONL rotation,
|
||||||
|
fixed-cardinality metrics, diagnostic-capture warning, and non-executing replay
|
||||||
|
contract are specified in
|
||||||
|
[`../../docs/grid-agent-observability.md`](../../docs/grid-agent-observability.md).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use crate::backend::BackendFuture;
|
use crate::backend::BackendFuture;
|
||||||
use crate::config::SecretString;
|
use crate::config::SecretString;
|
||||||
|
use crate::observability::{MetricsSnapshot, StructuredEvent};
|
||||||
use libremetaverse_types::compat::{CancellationToken, CancellationTokenSource};
|
use libremetaverse_types::compat::{CancellationToken, CancellationTokenSource};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
@@ -244,11 +245,13 @@ pub struct AuditEventView {
|
|||||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||||
pub enum ControlPayload {
|
pub enum ControlPayload {
|
||||||
Health(HealthView),
|
Health(HealthView),
|
||||||
|
Metrics(MetricsSnapshot),
|
||||||
Runtime(RuntimeView),
|
Runtime(RuntimeView),
|
||||||
Sessions(Page<SessionMetadataView>),
|
Sessions(Page<SessionMetadataView>),
|
||||||
ScheduledJobs(Page<ScheduledJobView>),
|
ScheduledJobs(Page<ScheduledJobView>),
|
||||||
PendingApprovals(Page<PendingApprovalView>),
|
PendingApprovals(Page<PendingApprovalView>),
|
||||||
AuditEvents(Page<AuditEventView>),
|
AuditEvents(Page<AuditEventView>),
|
||||||
|
ObservabilityEvents(Page<StructuredEvent>),
|
||||||
Accepted { operation_id: String },
|
Accepted { operation_id: String },
|
||||||
Completed,
|
Completed,
|
||||||
Subscribed { current_sequence: u64 },
|
Subscribed { current_sequence: u64 },
|
||||||
@@ -266,6 +269,7 @@ pub enum ConversationChannelView {
|
|||||||
#[serde(tag = "method", content = "parameters", rename_all = "snake_case")]
|
#[serde(tag = "method", content = "parameters", rename_all = "snake_case")]
|
||||||
pub enum ControlRequest {
|
pub enum ControlRequest {
|
||||||
Health,
|
Health,
|
||||||
|
Metrics,
|
||||||
Runtime,
|
Runtime,
|
||||||
ListSessions {
|
ListSessions {
|
||||||
page: PageRequest,
|
page: PageRequest,
|
||||||
@@ -279,6 +283,9 @@ pub enum ControlRequest {
|
|||||||
ListAuditEvents {
|
ListAuditEvents {
|
||||||
page: PageRequest,
|
page: PageRequest,
|
||||||
},
|
},
|
||||||
|
ListObservabilityEvents {
|
||||||
|
page: PageRequest,
|
||||||
|
},
|
||||||
SubscribeEvents {
|
SubscribeEvents {
|
||||||
after_sequence: Option<u64>,
|
after_sequence: Option<u64>,
|
||||||
},
|
},
|
||||||
@@ -326,11 +333,13 @@ impl ControlRequest {
|
|||||||
!matches!(
|
!matches!(
|
||||||
self,
|
self,
|
||||||
Self::Health
|
Self::Health
|
||||||
|
| Self::Metrics
|
||||||
| Self::Runtime
|
| Self::Runtime
|
||||||
| Self::ListSessions { .. }
|
| Self::ListSessions { .. }
|
||||||
| Self::ListScheduledJobs { .. }
|
| Self::ListScheduledJobs { .. }
|
||||||
| Self::ListPendingApprovals { .. }
|
| Self::ListPendingApprovals { .. }
|
||||||
| Self::ListAuditEvents { .. }
|
| Self::ListAuditEvents { .. }
|
||||||
|
| Self::ListObservabilityEvents { .. }
|
||||||
| Self::SubscribeEvents { .. }
|
| Self::SubscribeEvents { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -340,7 +349,8 @@ impl ControlRequest {
|
|||||||
Self::ListSessions { page }
|
Self::ListSessions { page }
|
||||||
| Self::ListScheduledJobs { page }
|
| Self::ListScheduledJobs { page }
|
||||||
| Self::ListPendingApprovals { page }
|
| Self::ListPendingApprovals { page }
|
||||||
| Self::ListAuditEvents { page } => page.valid(),
|
| Self::ListAuditEvents { page }
|
||||||
|
| Self::ListObservabilityEvents { page } => page.valid(),
|
||||||
Self::CancelRequest { target_request_id } => valid_identifier(target_request_id),
|
Self::CancelRequest { target_request_id } => valid_identifier(target_request_id),
|
||||||
Self::CancelAction { action_id } => valid_identifier(action_id),
|
Self::CancelAction { action_id } => valid_identifier(action_id),
|
||||||
Self::DecideApproval { approval_id, .. } => *approval_id != 0,
|
Self::DecideApproval { approval_id, .. } => *approval_id != 0,
|
||||||
@@ -952,7 +962,7 @@ enum ClientFrame {
|
|||||||
#[serde(tag = "frame", content = "body", rename_all = "snake_case")]
|
#[serde(tag = "frame", content = "body", rename_all = "snake_case")]
|
||||||
enum ServerFrame {
|
enum ServerFrame {
|
||||||
Hello(ServerHello),
|
Hello(ServerHello),
|
||||||
Response(ControlResponseEnvelope),
|
Response(Box<ControlResponseEnvelope>),
|
||||||
Event(ControlEvent),
|
Event(ControlEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1255,10 @@ where
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if outbound.try_send(ServerFrame::Response(response)).is_err() {
|
if outbound
|
||||||
|
.try_send(ServerFrame::Response(Box::new(response)))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -1256,7 +1269,7 @@ where
|
|||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
let (response, subscription) = request_core.request(envelope).await;
|
let (response, subscription) = request_core.request(envelope).await;
|
||||||
if request_outbound
|
if request_outbound
|
||||||
.try_send(ServerFrame::Response(response))
|
.try_send(ServerFrame::Response(Box::new(response)))
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
request_cancel.cancel();
|
request_cancel.cancel();
|
||||||
@@ -1534,7 +1547,7 @@ async fn tcp_client_reader(
|
|||||||
match frame {
|
match frame {
|
||||||
Ok(ServerFrame::Response(response)) => {
|
Ok(ServerFrame::Response(response)) => {
|
||||||
if let Some(sender) = lock(&shared.pending).remove(&response.request_id) {
|
if let Some(sender) = lock(&shared.pending).remove(&response.request_id) {
|
||||||
let _ = sender.send(response);
|
let _ = sender.send(*response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ServerFrame::Event(event)) => {
|
Ok(ServerFrame::Event(event)) => {
|
||||||
@@ -1744,11 +1757,13 @@ fn role_name(role: ControlRole) -> &'static str {
|
|||||||
fn request_name(request: &ControlRequest) -> &'static str {
|
fn request_name(request: &ControlRequest) -> &'static str {
|
||||||
match request {
|
match request {
|
||||||
ControlRequest::Health => "health",
|
ControlRequest::Health => "health",
|
||||||
|
ControlRequest::Metrics => "metrics",
|
||||||
ControlRequest::Runtime => "runtime",
|
ControlRequest::Runtime => "runtime",
|
||||||
ControlRequest::ListSessions { .. } => "list_sessions",
|
ControlRequest::ListSessions { .. } => "list_sessions",
|
||||||
ControlRequest::ListScheduledJobs { .. } => "list_scheduled_jobs",
|
ControlRequest::ListScheduledJobs { .. } => "list_scheduled_jobs",
|
||||||
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
|
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
|
||||||
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
|
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
|
||||||
|
ControlRequest::ListObservabilityEvents { .. } => "list_observability_events",
|
||||||
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
|
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
|
||||||
ControlRequest::CancelRequest { .. } => "cancel_request",
|
ControlRequest::CancelRequest { .. } => "cancel_request",
|
||||||
ControlRequest::PauseAutonomy => "pause_autonomy",
|
ControlRequest::PauseAutonomy => "pause_autonomy",
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ use crate::control_plane::{
|
|||||||
SessionMetadataView,
|
SessionMetadataView,
|
||||||
};
|
};
|
||||||
use crate::conversation::{ConversationChannel, ConversationKey, ConversationStore};
|
use crate::conversation::{ConversationChannel, ConversationKey, ConversationStore};
|
||||||
|
use crate::observability::{
|
||||||
|
CorrelationIds, EventDraft, EventFamily, EventOrigin, EventSeverity, MetricsSnapshot,
|
||||||
|
Observability, pseudonymous_identifier,
|
||||||
|
};
|
||||||
use crate::policy::{
|
use crate::policy::{
|
||||||
ApprovalId, AuthenticatedPrincipal, MemoryPolicyAudit, PolicyFinalOutcome, PolicyGateway,
|
ApprovalId, AuthenticatedPrincipal, MemoryPolicyAudit, PolicyFinalOutcome, PolicyGateway,
|
||||||
PolicyReasonCode,
|
PolicyReasonCode,
|
||||||
@@ -79,6 +83,7 @@ pub struct AgentControlTarget {
|
|||||||
conversations: Arc<ConversationStore>,
|
conversations: Arc<ConversationStore>,
|
||||||
policy: Arc<PolicyGateway>,
|
policy: Arc<PolicyGateway>,
|
||||||
audit: Arc<MemoryPolicyAudit>,
|
audit: Arc<MemoryPolicyAudit>,
|
||||||
|
observability: Mutex<Option<Arc<Observability>>>,
|
||||||
behavior: BehaviorIngress,
|
behavior: BehaviorIngress,
|
||||||
commands: mpsc::Sender<RuntimeControlCommand>,
|
commands: mpsc::Sender<RuntimeControlCommand>,
|
||||||
command_capacity: usize,
|
command_capacity: usize,
|
||||||
@@ -121,6 +126,7 @@ impl AgentControlTarget {
|
|||||||
conversations,
|
conversations,
|
||||||
policy,
|
policy,
|
||||||
audit,
|
audit,
|
||||||
|
observability: Mutex::new(None),
|
||||||
behavior,
|
behavior,
|
||||||
commands,
|
commands,
|
||||||
command_capacity,
|
command_capacity,
|
||||||
@@ -131,6 +137,11 @@ impl AgentControlTarget {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches the unified recorder used by the live service and control API.
|
||||||
|
pub fn attach_observability(&self, observability: Arc<Observability>) {
|
||||||
|
*lock(&self.observability) = Some(observability);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -205,6 +216,24 @@ impl AgentControlTarget {
|
|||||||
protocol_version: CONTROL_PROTOCOL_VERSION,
|
protocol_version: CONTROL_PROTOCOL_VERSION,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
ControlRequest::Metrics => {
|
||||||
|
let mut metrics = lock(&self.observability)
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(MetricsSnapshot::default, |observer| {
|
||||||
|
observer.metrics().snapshot()
|
||||||
|
});
|
||||||
|
let state = lock(&self.state);
|
||||||
|
metrics.ready = state.session.agent_ready;
|
||||||
|
metrics.active_sessions =
|
||||||
|
u64::try_from(self.conversations.list_metadata().len()).unwrap_or(u64::MAX);
|
||||||
|
metrics.queue_depth = u64::try_from(
|
||||||
|
self.command_capacity
|
||||||
|
.saturating_sub(self.commands.capacity()),
|
||||||
|
)
|
||||||
|
.unwrap_or(u64::MAX);
|
||||||
|
metrics.queue_capacity = u64::try_from(self.command_capacity).unwrap_or(u64::MAX);
|
||||||
|
Ok(ControlPayload::Metrics(metrics))
|
||||||
|
}
|
||||||
ControlRequest::Runtime => {
|
ControlRequest::Runtime => {
|
||||||
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();
|
||||||
@@ -284,7 +313,7 @@ impl AgentControlTarget {
|
|||||||
.map(|(index, record)| AuditEventView {
|
.map(|(index, record)| AuditEventView {
|
||||||
sequence: u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1),
|
sequence: u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1),
|
||||||
unix_millis: record.recorded_unix_millis,
|
unix_millis: record.recorded_unix_millis,
|
||||||
principal: record.principal.as_str().to_owned(),
|
principal: pseudonymous_identifier(record.principal.as_str()),
|
||||||
operation: record.tool.as_str().to_owned(),
|
operation: record.tool.as_str().to_owned(),
|
||||||
outcome: policy_outcome_name(record.final_outcome).to_owned(),
|
outcome: policy_outcome_name(record.final_outcome).to_owned(),
|
||||||
authorization_id: record.authorization_id,
|
authorization_id: record.authorization_id,
|
||||||
@@ -292,6 +321,14 @@ impl AgentControlTarget {
|
|||||||
.collect();
|
.collect();
|
||||||
Ok(ControlPayload::AuditEvents(page_values(&page, values)))
|
Ok(ControlPayload::AuditEvents(page_values(&page, values)))
|
||||||
}
|
}
|
||||||
|
ControlRequest::ListObservabilityEvents { page } => {
|
||||||
|
let values = lock(&self.observability)
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(Vec::new, |observer| observer.snapshot());
|
||||||
|
Ok(ControlPayload::ObservabilityEvents(page_values(
|
||||||
|
&page, values,
|
||||||
|
)))
|
||||||
|
}
|
||||||
ControlRequest::PauseAutonomy => {
|
ControlRequest::PauseAutonomy => {
|
||||||
let response = self.enqueue("pause", RuntimeControlCommand::Pause)?;
|
let response = self.enqueue("pause", RuntimeControlCommand::Pause)?;
|
||||||
self.behavior.pause();
|
self.behavior.pause();
|
||||||
@@ -452,11 +489,185 @@ impl ControlTarget for AgentControlTarget {
|
|||||||
false,
|
false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.execute_now(&context, request)
|
let operation = runtime_request_name(&request);
|
||||||
|
let correlation = request_correlation(&request);
|
||||||
|
let domain_event = runtime_domain_event(&request).ok().flatten();
|
||||||
|
let result = self.execute_now(&context, request);
|
||||||
|
if let Some(observability) = lock(&self.observability).as_ref() {
|
||||||
|
let (severity, result_code) = match &result {
|
||||||
|
Ok(_) => (EventSeverity::Info, "accepted"),
|
||||||
|
Err(error) => (
|
||||||
|
if error.retryable {
|
||||||
|
EventSeverity::Warning
|
||||||
|
} else {
|
||||||
|
EventSeverity::Error
|
||||||
|
},
|
||||||
|
control_error_name(error.code),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let event = EventDraft::new(
|
||||||
|
EventFamily::ControlCommand,
|
||||||
|
severity,
|
||||||
|
"control",
|
||||||
|
EventOrigin::Control,
|
||||||
|
)
|
||||||
|
.and_then(|event| event.correlation(correlation))
|
||||||
|
.and_then(|event| event.result_code(result_code))
|
||||||
|
.and_then(|event| event.code_field("operation", operation))
|
||||||
|
.and_then(|event| {
|
||||||
|
event.code_field(
|
||||||
|
"role",
|
||||||
|
match context.role {
|
||||||
|
crate::control_plane::ControlRole::Observer => "observer",
|
||||||
|
crate::control_plane::ControlRole::Operator => "operator",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.and_then(|event| event.redacted("operator_payload"));
|
||||||
|
if let Ok(event) = event {
|
||||||
|
let _ = observability.record(event);
|
||||||
|
}
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Some(event) = domain_event
|
||||||
|
{
|
||||||
|
let _ = observability.record(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_domain_event(
|
||||||
|
request: &ControlRequest,
|
||||||
|
) -> Result<Option<EventDraft>, crate::observability::ObservabilityError> {
|
||||||
|
let event = match request {
|
||||||
|
ControlRequest::SetRoamingJob { job_id, enabled } => Some(
|
||||||
|
EventDraft::new(
|
||||||
|
EventFamily::ScheduledJob,
|
||||||
|
EventSeverity::Info,
|
||||||
|
"scheduler",
|
||||||
|
EventOrigin::Control,
|
||||||
|
)?
|
||||||
|
.correlation(CorrelationIds {
|
||||||
|
action_id: Some(job_id.clone()),
|
||||||
|
..CorrelationIds::default()
|
||||||
|
})?
|
||||||
|
.result_code("accepted")?
|
||||||
|
.code_field("state", if *enabled { "enabled" } else { "disabled" })?,
|
||||||
|
),
|
||||||
|
ControlRequest::ExpireConversation { avatar_id, channel } => Some(
|
||||||
|
EventDraft::new(
|
||||||
|
EventFamily::SessionExpired,
|
||||||
|
EventSeverity::Info,
|
||||||
|
"conversation",
|
||||||
|
EventOrigin::Control,
|
||||||
|
)?
|
||||||
|
.correlation(CorrelationIds {
|
||||||
|
avatar_id: Some(avatar_id.clone()),
|
||||||
|
..CorrelationIds::default()
|
||||||
|
})?
|
||||||
|
.result_code("completed")?
|
||||||
|
.reason_code("operator_expired")?
|
||||||
|
.code_field(
|
||||||
|
"channel",
|
||||||
|
match channel {
|
||||||
|
ConversationChannelView::PublicChat => "public_chat",
|
||||||
|
ConversationChannelView::DirectIm => "direct_im",
|
||||||
|
},
|
||||||
|
)?,
|
||||||
|
),
|
||||||
|
ControlRequest::DecideApproval {
|
||||||
|
approval_id,
|
||||||
|
approve,
|
||||||
|
} => Some(
|
||||||
|
EventDraft::new(
|
||||||
|
EventFamily::ApprovalDecision,
|
||||||
|
EventSeverity::Info,
|
||||||
|
"policy",
|
||||||
|
EventOrigin::Control,
|
||||||
|
)?
|
||||||
|
.correlation(CorrelationIds {
|
||||||
|
action_id: Some(format!("approval-{approval_id}")),
|
||||||
|
..CorrelationIds::default()
|
||||||
|
})?
|
||||||
|
.result_code("completed")?
|
||||||
|
.code_field("decision", if *approve { "approved" } else { "denied" })?,
|
||||||
|
),
|
||||||
|
ControlRequest::GracefulShutdown => Some(
|
||||||
|
EventDraft::new(
|
||||||
|
EventFamily::Shutdown,
|
||||||
|
EventSeverity::Info,
|
||||||
|
"service",
|
||||||
|
EventOrigin::Control,
|
||||||
|
)?
|
||||||
|
.result_code("requested")?,
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_correlation(request: &ControlRequest) -> CorrelationIds {
|
||||||
|
let action_id = match request {
|
||||||
|
ControlRequest::CancelAction { action_id } => Some(action_id.clone()),
|
||||||
|
ControlRequest::DecideApproval { approval_id, .. } => {
|
||||||
|
Some(format!("approval-{approval_id}"))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
CorrelationIds {
|
||||||
|
action_id,
|
||||||
|
..CorrelationIds::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn runtime_request_name(request: &ControlRequest) -> &'static str {
|
||||||
|
match request {
|
||||||
|
ControlRequest::Health => "health",
|
||||||
|
ControlRequest::Metrics => "metrics",
|
||||||
|
ControlRequest::Runtime => "runtime",
|
||||||
|
ControlRequest::ListSessions { .. } => "list_sessions",
|
||||||
|
ControlRequest::ListScheduledJobs { .. } => "list_scheduled_jobs",
|
||||||
|
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
|
||||||
|
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
|
||||||
|
ControlRequest::ListObservabilityEvents { .. } => "list_observability_events",
|
||||||
|
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
|
||||||
|
ControlRequest::CancelRequest { .. } => "cancel_request",
|
||||||
|
ControlRequest::PauseAutonomy => "pause_autonomy",
|
||||||
|
ControlRequest::ResumeAutonomy => "resume_autonomy",
|
||||||
|
ControlRequest::CancelAction { .. } => "cancel_action",
|
||||||
|
ControlRequest::DecideApproval { approve: true, .. } => "approve_proposal",
|
||||||
|
ControlRequest::DecideApproval { approve: false, .. } => "deny_proposal",
|
||||||
|
ControlRequest::ForceReconnect => "force_reconnect",
|
||||||
|
ControlRequest::ExpireConversation { .. } => "expire_conversation",
|
||||||
|
ControlRequest::SetRoamingJob { enabled: true, .. } => "enable_roaming_job",
|
||||||
|
ControlRequest::SetRoamingJob { enabled: false, .. } => "disable_roaming_job",
|
||||||
|
ControlRequest::InjectOperatorMessage { .. } => "inject_operator_message",
|
||||||
|
ControlRequest::GracefulShutdown => "graceful_shutdown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn control_error_name(code: ControlErrorCode) -> &'static str {
|
||||||
|
match code {
|
||||||
|
ControlErrorCode::AuthenticationFailed => "authentication_failed",
|
||||||
|
ControlErrorCode::VersionMismatch => "version_mismatch",
|
||||||
|
ControlErrorCode::PermissionDenied => "permission_denied",
|
||||||
|
ControlErrorCode::InvalidRequest => "invalid_request",
|
||||||
|
ControlErrorCode::Replay => "replay",
|
||||||
|
ControlErrorCode::NotFound => "not_found",
|
||||||
|
ControlErrorCode::Conflict => "conflict",
|
||||||
|
ControlErrorCode::Cancelled => "cancelled",
|
||||||
|
ControlErrorCode::TimedOut => "timed_out",
|
||||||
|
ControlErrorCode::Busy => "busy",
|
||||||
|
ControlErrorCode::Backpressure => "backpressure",
|
||||||
|
ControlErrorCode::FrameTooLarge => "frame_too_large",
|
||||||
|
ControlErrorCode::IdleTimeout => "idle_timeout",
|
||||||
|
ControlErrorCode::TransportClosed => "transport_closed",
|
||||||
|
ControlErrorCode::Internal => "internal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn page_values<T>(request: &PageRequest, values: Vec<T>) -> Page<T> {
|
fn page_values<T>(request: &PageRequest, values: Vec<T>) -> Page<T> {
|
||||||
let start = usize::try_from(request.cursor.unwrap_or(0))
|
let start = usize::try_from(request.cursor.unwrap_or(0))
|
||||||
.unwrap_or(usize::MAX)
|
.unwrap_or(usize::MAX)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use crate::behavior::{BehaviorController, BehaviorError, EmbodimentFuture, EmbodimentSink};
|
use crate::behavior::{BehaviorController, BehaviorError, EmbodimentFuture, EmbodimentSink};
|
||||||
use crate::control_plane::{
|
use crate::control_plane::{
|
||||||
CONTROL_PROTOCOL_VERSION, ControlLimits, ControlPayload, ControlPlane, ControlRequest,
|
CONTROL_PROTOCOL_VERSION, ControlLimits, ControlPayload, ControlPlane, ControlRequest,
|
||||||
ControlRequestEnvelope,
|
ControlRequestEnvelope, PageRequest,
|
||||||
};
|
};
|
||||||
use crate::control_runtime::{AgentControlTarget, RuntimeControlCommand};
|
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, SecretString};
|
use crate::{AgentConfig, EmbodiedPose, Observability, ObservabilityLimits, SecretString};
|
||||||
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;
|
||||||
@@ -123,13 +123,15 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
|||||||
let (target, mut commands) =
|
let (target, mut commands) =
|
||||||
AgentControlTarget::new(conversations, policy, audit, behavior.ingress(), 8)
|
AgentControlTarget::new(conversations, policy, audit, behavior.ingress(), 8)
|
||||||
.expect("target");
|
.expect("target");
|
||||||
|
let observability = Observability::memory(ObservabilityLimits::default()).expect("events");
|
||||||
|
target.attach_observability(observability);
|
||||||
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()),
|
||||||
Some([128.0, 128.0, 24.0]),
|
Some([128.0, 128.0, 24.0]),
|
||||||
);
|
);
|
||||||
let plane = ControlPlane::new(
|
let plane = ControlPlane::new(
|
||||||
target,
|
target.clone(),
|
||||||
SecretString::new("test.operator", "operator-token").expect("token"),
|
SecretString::new("test.operator", "operator-token").expect("token"),
|
||||||
Some(SecretString::new("test.observer", "observer-token").expect("token")),
|
Some(SecretString::new("test.observer", "observer-token").expect("token")),
|
||||||
ControlLimits::default(),
|
ControlLimits::default(),
|
||||||
@@ -144,6 +146,13 @@ 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);
|
||||||
|
let metrics = operator
|
||||||
|
.request(envelope("metrics", ControlRequest::Metrics))
|
||||||
|
.await;
|
||||||
|
let Ok(ControlPayload::Metrics(metrics)) = metrics.result else {
|
||||||
|
panic!("metrics projection")
|
||||||
|
};
|
||||||
|
assert_eq!(metrics.queue_capacity, 8);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
operator
|
operator
|
||||||
@@ -163,5 +172,30 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
|||||||
.await;
|
.await;
|
||||||
assert!(denied.result.is_err());
|
assert!(denied.result.is_err());
|
||||||
assert!(commands.try_recv().is_err());
|
assert!(commands.try_recv().is_err());
|
||||||
|
let events = observer
|
||||||
|
.request(envelope(
|
||||||
|
"events",
|
||||||
|
ControlRequest::ListObservabilityEvents {
|
||||||
|
page: PageRequest {
|
||||||
|
cursor: None,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let Ok(ControlPayload::ObservabilityEvents(events)) = events.result else {
|
||||||
|
panic!("observability events")
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
events
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.family == "control_command")
|
||||||
|
);
|
||||||
|
assert!(events.items.iter().all(|event| {
|
||||||
|
!serde_json::to_string(event)
|
||||||
|
.expect("event JSON")
|
||||||
|
.contains("operator-token")
|
||||||
|
}));
|
||||||
behavior.shutdown().await.expect("shutdown behavior");
|
behavior.shutdown().await.expect("shutdown behavior");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ impl MemoryRecord {
|
|||||||
/// Stable, content-free reasons suitable for audit and observability.
|
/// Stable, content-free reasons suitable for audit and observability.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum MemoryReason {
|
pub enum MemoryReason {
|
||||||
|
SessionCreated,
|
||||||
PublicExpired,
|
PublicExpired,
|
||||||
DirectImExpired,
|
DirectImExpired,
|
||||||
TurnCompacted,
|
TurnCompacted,
|
||||||
@@ -542,6 +543,10 @@ impl ConversationStore {
|
|||||||
tool_results: 0,
|
tool_results: 0,
|
||||||
};
|
};
|
||||||
state.sessions.insert(key, session);
|
state.sessions.insert(key, session);
|
||||||
|
if let Some(session) = state.sessions.get(&key) {
|
||||||
|
let event = event_for(MemoryReason::SessionCreated, session);
|
||||||
|
push_event(&mut state.events, event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let effective_wall = state.sessions.get(&key).map_or(wall_now, |session| {
|
let effective_wall = state.sessions.get(&key).map_or(wall_now, |session| {
|
||||||
wall_now.max(session.last_active_unix_millis)
|
wall_now.max(session.last_active_unix_millis)
|
||||||
|
|||||||
@@ -374,8 +374,20 @@ pub enum DeliveryOutcome {
|
|||||||
PolicyDenied,
|
PolicyDenied,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum InferenceOutcome {
|
||||||
|
Completed,
|
||||||
|
Cancelled,
|
||||||
|
TimedOut,
|
||||||
|
Failed,
|
||||||
|
PolicyRejected,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum InteractionObservation {
|
pub enum InteractionObservation {
|
||||||
|
SessionMemory {
|
||||||
|
event: crate::conversation::MemoryEvent,
|
||||||
|
},
|
||||||
Suppressed {
|
Suppressed {
|
||||||
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
reason: SuppressionReason,
|
reason: SuppressionReason,
|
||||||
@@ -390,6 +402,17 @@ pub enum InteractionObservation {
|
|||||||
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
avatar_id: UUID,
|
avatar_id: UUID,
|
||||||
},
|
},
|
||||||
|
InferenceStarted {
|
||||||
|
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
|
session_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
|
prompt_messages: usize,
|
||||||
|
},
|
||||||
|
InferenceFinished {
|
||||||
|
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
|
session_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
|
outcome: InferenceOutcome,
|
||||||
|
duration_millis: u64,
|
||||||
|
},
|
||||||
Delivery {
|
Delivery {
|
||||||
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
delivery_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
session_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
session_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
||||||
@@ -1044,6 +1067,12 @@ async fn process_batch(
|
|||||||
(InteractionChannel::DirectIm, false) => InteractionOrigin::UnprivilegedIm,
|
(InteractionChannel::DirectIm, false) => InteractionOrigin::UnprivilegedIm,
|
||||||
};
|
};
|
||||||
let intent = classify_intent(trigger.channel, authorized, &body);
|
let intent = classify_intent(trigger.channel, authorized, &body);
|
||||||
|
for event in update.events.iter().cloned() {
|
||||||
|
observe(
|
||||||
|
&observations,
|
||||||
|
InteractionObservation::SessionMemory { event },
|
||||||
|
);
|
||||||
|
}
|
||||||
observe(
|
observe(
|
||||||
&observations,
|
&observations,
|
||||||
InteractionObservation::IntentRouted {
|
InteractionObservation::IntentRouted {
|
||||||
@@ -1104,12 +1133,38 @@ async fn process_batch(
|
|||||||
intent,
|
intent,
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
|
observe(
|
||||||
|
&observations,
|
||||||
|
InteractionObservation::InferenceStarted {
|
||||||
|
delivery_id: delivery_id.clone(),
|
||||||
|
session_id: update.session_id.clone(),
|
||||||
|
prompt_messages: request.messages.len(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let inference_started = Instant::now();
|
||||||
let model = tokio::select! {
|
let model = tokio::select! {
|
||||||
() = cancellation.cancelled() => Err(InteractionModelError::Cancelled),
|
() = cancellation.cancelled() => Err(InteractionModelError::Cancelled),
|
||||||
result = tokio::time::timeout(settings.model_timeout, responder.respond(request, cancellation.clone())) => {
|
result = tokio::time::timeout(settings.model_timeout, responder.respond(request, cancellation.clone())) => {
|
||||||
result.unwrap_or(Err(InteractionModelError::Timeout))
|
result.unwrap_or(Err(InteractionModelError::Timeout))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let inference_outcome = match &model {
|
||||||
|
Ok(_) => InferenceOutcome::Completed,
|
||||||
|
Err(InteractionModelError::Cancelled) => InferenceOutcome::Cancelled,
|
||||||
|
Err(InteractionModelError::Timeout) => InferenceOutcome::TimedOut,
|
||||||
|
Err(InteractionModelError::Failed) => InferenceOutcome::Failed,
|
||||||
|
Err(InteractionModelError::PolicyRejected) => InferenceOutcome::PolicyRejected,
|
||||||
|
};
|
||||||
|
observe(
|
||||||
|
&observations,
|
||||||
|
InteractionObservation::InferenceFinished {
|
||||||
|
delivery_id: delivery_id.clone(),
|
||||||
|
session_id: update.session_id.clone(),
|
||||||
|
outcome: inference_outcome,
|
||||||
|
duration_millis: u64::try_from(inference_started.elapsed().as_millis())
|
||||||
|
.unwrap_or(u64::MAX),
|
||||||
|
},
|
||||||
|
);
|
||||||
match model {
|
match model {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let Some(safe) = safe_visible_response(response.as_str(), settings.max_response_bytes)
|
let Some(safe) = safe_visible_response(response.as_str(), settings.max_response_bytes)
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ async fn observation_for(
|
|||||||
| InteractionObservation::Delivery {
|
| InteractionObservation::Delivery {
|
||||||
delivery_id: id, ..
|
delivery_id: id, ..
|
||||||
} => id.as_str() == delivery_id,
|
} => id.as_str() == delivery_id,
|
||||||
|
InteractionObservation::SessionMemory { .. }
|
||||||
|
| InteractionObservation::InferenceStarted { .. }
|
||||||
|
| InteractionObservation::InferenceFinished { .. } => false,
|
||||||
};
|
};
|
||||||
if matches {
|
if matches {
|
||||||
return observation;
|
return observation;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod control_runtime;
|
|||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
|
pub mod observability;
|
||||||
pub mod perception;
|
pub mod perception;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
@@ -30,6 +31,8 @@ mod conversation_tests;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod interaction_tests;
|
mod interaction_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod observability_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod perception_tests;
|
mod perception_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod policy_tests;
|
mod policy_tests;
|
||||||
@@ -74,17 +77,25 @@ pub use conversation::{
|
|||||||
};
|
};
|
||||||
pub use interaction::{
|
pub use interaction::{
|
||||||
DeliveryFuture, DeliveryOutcome, ImDialogKind, InboundInteraction, InboundSource,
|
DeliveryFuture, DeliveryOutcome, ImDialogKind, InboundInteraction, InboundSource,
|
||||||
InteractionChannel, InteractionCoordinator, InteractionDeliveryError, InteractionError,
|
InferenceOutcome, InteractionChannel, InteractionCoordinator, InteractionDeliveryError,
|
||||||
InteractionHandle, InteractionIngress, InteractionIntent, InteractionModelError,
|
InteractionError, InteractionHandle, InteractionIngress, InteractionIntent,
|
||||||
InteractionObservation, InteractionOrigin, InteractionPacingError, InteractionResponder,
|
InteractionModelError, InteractionObservation, InteractionOrigin, InteractionPacingError,
|
||||||
InteractionSettings, InteractionSink, OutboundInteraction, PacerFuture, PolicyLlmResponder,
|
InteractionResponder, InteractionSettings, InteractionSink, OutboundInteraction, PacerFuture,
|
||||||
ResponderFuture, ResponsePacer, ResponseRequest, SuppressionReason, VisibleResponse,
|
PolicyLlmResponder, ResponderFuture, ResponsePacer, ResponseRequest, SuppressionReason,
|
||||||
split_utf8,
|
VisibleResponse, split_utf8,
|
||||||
};
|
};
|
||||||
pub use llm::{
|
pub use llm::{
|
||||||
Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError,
|
Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError,
|
||||||
LlmTransportLimits, ToolDefinition, ToolSchema, Usage,
|
LlmTransportLimits, ToolDefinition, ToolSchema, Usage,
|
||||||
};
|
};
|
||||||
|
pub use observability::{
|
||||||
|
AgentMetrics, CorrelationIds, DiagnosticDirection, EventDraft, EventFamily, EventOrigin,
|
||||||
|
EventSeverity, EventSubscription, JournalConfig, LatencyMetrics, MetricsSnapshot,
|
||||||
|
OBSERVABILITY_SCHEMA_VERSION, Observability, ObservabilityError, ObservabilityLimits,
|
||||||
|
ObservabilityRuntime, OutcomeCode, OutcomeMetrics, ReplayAction, ReplayState,
|
||||||
|
SensitiveDiagnosticPart, StructuredEvent, UnifiedPolicyAudit, event_json,
|
||||||
|
pseudonymous_identifier, replay_journal,
|
||||||
|
};
|
||||||
pub use perception::{
|
pub use perception::{
|
||||||
AGENT_STATE_TOOL, AgentSnapshot, EnvironmentSnapshot, INVENTORY_SEARCH_TOOL, InventoryMetadata,
|
AGENT_STATE_TOOL, AgentSnapshot, EnvironmentSnapshot, INVENTORY_SEARCH_TOOL, InventoryMetadata,
|
||||||
LOCATION_TOOL, LandmarkMetadata, NEARBY_AVATARS_TOOL, ObservedAvatar, ObservedObject,
|
LOCATION_TOOL, LandmarkMetadata, NEARBY_AVATARS_TOOL, ObservedAvatar, ObservedObject,
|
||||||
@@ -105,9 +116,9 @@ pub use policy::{
|
|||||||
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
|
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
|
||||||
pub use session::{
|
pub use session::{
|
||||||
GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, SessionFailure,
|
GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, SessionFailure,
|
||||||
SessionFailureKind, SessionFuture, SessionObservation, SessionSignal, SessionState,
|
SessionFailureKind, SessionFuture, SessionObservation, SessionReason, SessionSignal,
|
||||||
SessionStatus, SessionSupervisor, SessionSupervisorError, SessionSupervisorHandle, SessionWork,
|
SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
|
||||||
WorkDisposition, WorkKind,
|
SessionSupervisorHandle, SessionWork, WorkDisposition, WorkKind,
|
||||||
};
|
};
|
||||||
pub use tool_loop::{
|
pub use tool_loop::{
|
||||||
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
|
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::path::PathBuf;
|
|||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
const MAX_ARGUMENTS: usize = 8;
|
const MAX_ARGUMENTS: usize = 8;
|
||||||
|
|
||||||
@@ -184,22 +184,28 @@ async fn run_live(
|
|||||||
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
||||||
loop {
|
loop {
|
||||||
match handle.next_observation().await {
|
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>(());
|
return Ok::<(), CliError>(());
|
||||||
}
|
}
|
||||||
Some(SessionObservation::Transition {
|
Some(
|
||||||
status:
|
event @ SessionObservation::Transition {
|
||||||
metacrate_grid_agent::SessionStatus {
|
status:
|
||||||
state: SessionState::AuthenticationBlocked,
|
metacrate_grid_agent::SessionStatus {
|
||||||
..
|
state: SessionState::AuthenticationBlocked,
|
||||||
},
|
..
|
||||||
..
|
},
|
||||||
}) => {
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
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(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(_) => {}
|
Some(event) => record_session_observation(&live.observability, &event),
|
||||||
None => {
|
None => {
|
||||||
return Err(CliError(
|
return Err(CliError(
|
||||||
"session supervisor stopped before readiness".into(),
|
"session supervisor stopped before readiness".into(),
|
||||||
@@ -239,6 +245,7 @@ async fn run_live(
|
|||||||
live.behavior.ingress(),
|
live.behavior.ingress(),
|
||||||
config.limits.control_queue,
|
config.limits.control_queue,
|
||||||
)?;
|
)?;
|
||||||
|
control_target.attach_observability(live.observability.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 {
|
||||||
@@ -292,6 +299,7 @@ async fn run_live(
|
|||||||
}
|
}
|
||||||
event = handle.next_observation() => {
|
event = handle.next_observation() => {
|
||||||
let Some(event) = event else { break; };
|
let Some(event) = event else { break; };
|
||||||
|
record_session_observation(&live.observability, &event);
|
||||||
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
||||||
control_target.update_session(status);
|
control_target.update_session(status);
|
||||||
control_plane.publish(ControlEventKind::StateChanged {
|
control_plane.publish(ControlEventKind::StateChanged {
|
||||||
@@ -309,14 +317,17 @@ async fn run_live(
|
|||||||
}
|
}
|
||||||
event = live.interaction.next_observation() => {
|
event = live.interaction.next_observation() => {
|
||||||
let Some(event) = event else { break; };
|
let Some(event) = event else { break; };
|
||||||
|
record_interaction_observation(&live.observability, &event);
|
||||||
println!("grid interaction event={event:?}");
|
println!("grid interaction event={event:?}");
|
||||||
}
|
}
|
||||||
event = live.perception_observations.recv() => {
|
event = live.perception_observations.recv() => {
|
||||||
let Some(event) = event else { break; };
|
let Some(event) = event else { break; };
|
||||||
|
record_perception_observation(&live.observability, &event);
|
||||||
println!("grid perception event={event:?}");
|
println!("grid perception event={event:?}");
|
||||||
}
|
}
|
||||||
event = live.behavior.next_observation() => {
|
event = live.behavior.next_observation() => {
|
||||||
let Some(event) = event else { break; };
|
let Some(event) = event else { break; };
|
||||||
|
record_behavior_observation(&live.observability, &event);
|
||||||
if let BehaviorObservation::Transition { to, .. } = &event {
|
if let BehaviorObservation::Transition { to, .. } = &event {
|
||||||
control_target.update_behavior(*to);
|
control_target.update_behavior(*to);
|
||||||
control_plane.publish(ControlEventKind::StateChanged {
|
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();
|
control_target.mark_stopping();
|
||||||
let session_result = handle.shutdown().await;
|
let session_result = handle.shutdown().await;
|
||||||
let interaction_result = live.interaction.shutdown().await;
|
let interaction_result = live.interaction.shutdown().await;
|
||||||
@@ -391,6 +410,7 @@ struct LiveInteractions {
|
|||||||
conversations: Arc<metacrate_grid_agent::ConversationStore>,
|
conversations: Arc<metacrate_grid_agent::ConversationStore>,
|
||||||
policy: Arc<metacrate_grid_agent::PolicyGateway>,
|
policy: Arc<metacrate_grid_agent::PolicyGateway>,
|
||||||
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
|
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
|
||||||
|
observability: Arc<metacrate_grid_agent::Observability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
@@ -402,8 +422,9 @@ fn start_live_interactions(
|
|||||||
use metacrate_grid_agent::{
|
use metacrate_grid_agent::{
|
||||||
AuthorizedBackendRouter, AuthorizedToolBackend, BehaviorBackend, BehaviorController,
|
AuthorizedBackendRouter, AuthorizedToolBackend, BehaviorBackend, BehaviorController,
|
||||||
ConversationStore, InteractionCoordinator, LlmClient, LlmTransportLimits,
|
ConversationStore, InteractionCoordinator, LlmClient, LlmTransportLimits,
|
||||||
MemoryPolicyAudit, PerceptionBackend, PolicyGateway, PolicyLimits, PolicyLlmResponder,
|
MemoryPolicyAudit, Observability, ObservabilityLimits, PerceptionBackend, PolicyAuditSink,
|
||||||
ToolLoopLimits, behavior_policy_tools, perception_policy_tools,
|
PolicyGateway, PolicyLimits, PolicyLlmResponder, ToolLoopLimits, UnifiedPolicyAudit,
|
||||||
|
behavior_policy_tools, perception_policy_tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
let transport_limits = LlmTransportLimits {
|
let transport_limits = LlmTransportLimits {
|
||||||
@@ -435,6 +456,16 @@ fn start_live_interactions(
|
|||||||
let behavior_ingress = behavior.ingress();
|
let behavior_ingress = behavior.ingress();
|
||||||
let client = Arc::new(LlmClient::new(config.llm.clone(), transport_limits)?);
|
let client = Arc::new(LlmClient::new(config.llm.clone(), transport_limits)?);
|
||||||
let audit = Arc::new(MemoryPolicyAudit::new(config.limits.observable_queue)?);
|
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()?;
|
let mut tools = perception_policy_tools()?;
|
||||||
tools.extend(behavior_policy_tools(&config.behavior)?);
|
tools.extend(behavior_policy_tools(&config.behavior)?);
|
||||||
let routes = tools
|
let routes = tools
|
||||||
@@ -453,7 +484,7 @@ fn start_live_interactions(
|
|||||||
config.authorized_avatar_uuids.clone(),
|
config.authorized_avatar_uuids.clone(),
|
||||||
tools,
|
tools,
|
||||||
PolicyLimits::default(),
|
PolicyLimits::default(),
|
||||||
audit.clone(),
|
audit_sink,
|
||||||
)?);
|
)?);
|
||||||
let loop_limits = ToolLoopLimits {
|
let loop_limits = ToolLoopLimits {
|
||||||
max_tool_calls_per_turn: config.limits.max_tool_calls,
|
max_tool_calls_per_turn: config.limits.max_tool_calls,
|
||||||
@@ -500,5 +531,497 @@ fn start_live_interactions(
|
|||||||
conversations: conversation,
|
conversations: conversation,
|
||||||
policy: gateway,
|
policy: gateway,
|
||||||
audit,
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1679
crates/metacrate-grid-agent/src/observability.rs
Normal file
1679
crates/metacrate-grid-agent/src/observability.rs
Normal file
File diff suppressed because it is too large
Load Diff
423
crates/metacrate-grid-agent/src/observability_tests.rs
Normal file
423
crates/metacrate-grid-agent/src/observability_tests.rs
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
use crate::observability::{
|
||||||
|
CorrelationIds, DiagnosticDirection, EventDraft, EventFamily, EventOrigin, EventSeverity,
|
||||||
|
JournalConfig, OBSERVABILITY_SCHEMA_VERSION, Observability, ObservabilityLimits, OutcomeCode,
|
||||||
|
SensitiveDiagnosticPart, StructuredEvent, UnifiedPolicyAudit, replay_journal,
|
||||||
|
};
|
||||||
|
use crate::policy::{
|
||||||
|
MemoryPolicyAudit, OriginClass, PolicyAuditRecord, PolicyAuditSink, PolicyDisposition,
|
||||||
|
PolicyFinalOutcome, PolicyReasonCode, ResourceCost,
|
||||||
|
};
|
||||||
|
use crate::types::{BoundedText, MAX_IDENTIFIER_BYTES};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
const SECRET_CANARY: &str = "OBSERVABILITY-SECRET-CANARY-9f7d2";
|
||||||
|
|
||||||
|
fn limits() -> ObservabilityLimits {
|
||||||
|
ObservabilityLimits {
|
||||||
|
ring_events: 8,
|
||||||
|
subscriber_queue: 2,
|
||||||
|
max_subscribers: 2,
|
||||||
|
journal_queue: 8,
|
||||||
|
max_event_bytes: 4_096,
|
||||||
|
max_diagnostic_entries: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"metacrate-observability-{label}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&path).expect("create test directory");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draft(family: EventFamily, request: &str) -> EventDraft {
|
||||||
|
EventDraft::new(family, EventSeverity::Info, "test", EventOrigin::Service)
|
||||||
|
.expect("draft")
|
||||||
|
.correlation(CorrelationIds {
|
||||||
|
request_id: Some(request.to_owned()),
|
||||||
|
..CorrelationIds::default()
|
||||||
|
})
|
||||||
|
.expect("correlation")
|
||||||
|
.result_code("completed")
|
||||||
|
.expect("result")
|
||||||
|
.redacted("content_omitted")
|
||||||
|
.expect("redaction")
|
||||||
|
.field("count", Value::from(1))
|
||||||
|
.expect("field")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_schema_covers_every_event_family_and_preserves_extensions() {
|
||||||
|
let observer = Observability::memory(ObservabilityLimits {
|
||||||
|
ring_events: 32,
|
||||||
|
..limits()
|
||||||
|
})
|
||||||
|
.expect("observer");
|
||||||
|
let families = [
|
||||||
|
EventFamily::LifecycleTransition,
|
||||||
|
EventFamily::InboundMessage,
|
||||||
|
EventFamily::OutboundMessage,
|
||||||
|
EventFamily::SessionCreated,
|
||||||
|
EventFamily::SessionExpired,
|
||||||
|
EventFamily::InferenceRequest,
|
||||||
|
EventFamily::InferenceResult,
|
||||||
|
EventFamily::ModelActionSummary,
|
||||||
|
EventFamily::ToolProposed,
|
||||||
|
EventFamily::PolicyDecision,
|
||||||
|
EventFamily::ApprovalDecision,
|
||||||
|
EventFamily::ToolStarted,
|
||||||
|
EventFamily::ToolProgress,
|
||||||
|
EventFamily::ToolResult,
|
||||||
|
EventFamily::BehaviorTransition,
|
||||||
|
EventFamily::ScheduledJob,
|
||||||
|
EventFamily::ControlCommand,
|
||||||
|
EventFamily::Shutdown,
|
||||||
|
EventFamily::DiagnosticEnvelope,
|
||||||
|
];
|
||||||
|
for (index, family) in families.into_iter().enumerate() {
|
||||||
|
observer
|
||||||
|
.record(draft(family, &format!("request-{index}")))
|
||||||
|
.expect("record family");
|
||||||
|
}
|
||||||
|
let events = observer.snapshot();
|
||||||
|
assert_eq!(events.len(), families.len());
|
||||||
|
let event = events.last().expect("last event");
|
||||||
|
let value = serde_json::to_value(event).expect("JSON");
|
||||||
|
assert_eq!(value["schema_version"], OBSERVABILITY_SCHEMA_VERSION);
|
||||||
|
assert_eq!(value["family"], "diagnostic_envelope");
|
||||||
|
assert_eq!(value["redaction_flags"], json!(["content_omitted"]));
|
||||||
|
|
||||||
|
let mut value = value;
|
||||||
|
value["future_top_level"] = json!({"compatible": true});
|
||||||
|
value["fields"]["future_scalar"] = json!(42);
|
||||||
|
let decoded: StructuredEvent = serde_json::from_value(value).expect("future event");
|
||||||
|
assert_eq!(decoded.extensions["future_top_level"]["compatible"], true);
|
||||||
|
assert_eq!(decoded.fields["future_scalar"], 42);
|
||||||
|
let encoded = serde_json::to_value(decoded).expect("round trip");
|
||||||
|
assert_eq!(encoded["future_top_level"]["compatible"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_canaries_are_unrepresentable_and_debug_is_redacted() {
|
||||||
|
let observer = Observability::memory(limits()).expect("observer");
|
||||||
|
for sensitive in [
|
||||||
|
SECRET_CANARY,
|
||||||
|
"Bearer OBSERVABILITY-SECRET-CANARY-9f7d2",
|
||||||
|
"https://grid.example/CAPS/OBSERVABILITY-SECRET-CANARY-9f7d2",
|
||||||
|
"password=OBSERVABILITY-SECRET-CANARY-9f7d2",
|
||||||
|
"api_key=OBSERVABILITY-SECRET-CANARY-9f7d2",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
EventDraft::new(
|
||||||
|
EventFamily::InboundMessage,
|
||||||
|
EventSeverity::Info,
|
||||||
|
"test",
|
||||||
|
EventOrigin::Grid
|
||||||
|
)
|
||||||
|
.expect("draft")
|
||||||
|
.field("unsafe", Value::String(sensitive.to_owned()))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let part = SensitiveDiagnosticPart {
|
||||||
|
role: SECRET_CANARY,
|
||||||
|
contents: SECRET_CANARY,
|
||||||
|
};
|
||||||
|
assert!(!format!("{part:?}").contains(SECRET_CANARY));
|
||||||
|
observer
|
||||||
|
.capture_diagnostic(DiagnosticDirection::Prompt, "request-safe", &[part])
|
||||||
|
.expect("diagnostic metadata");
|
||||||
|
let rendered = serde_json::to_string(&observer.snapshot()).expect("render events");
|
||||||
|
assert!(!rendered.contains(SECRET_CANARY));
|
||||||
|
assert!(!format!("{observer:?}").contains(SECRET_CANARY));
|
||||||
|
assert!(rendered.contains("content_digest"));
|
||||||
|
assert!(rendered.contains("content_omitted"));
|
||||||
|
let mut external = serde_json::to_value(&observer.snapshot()[0]).expect("external event");
|
||||||
|
external["component"] = Value::String(SECRET_CANARY.to_owned());
|
||||||
|
external["family"] = Value::String(SECRET_CANARY.to_owned());
|
||||||
|
external["result_code"] = Value::String(SECRET_CANARY.to_owned());
|
||||||
|
external["reason_code"] = Value::String(SECRET_CANARY.to_owned());
|
||||||
|
external["redaction_flags"] = serde_json::json!([SECRET_CANARY]);
|
||||||
|
external["future_secret"] = Value::String(SECRET_CANARY.to_owned());
|
||||||
|
let external: StructuredEvent = serde_json::from_value(external).expect("external event");
|
||||||
|
assert!(!format!("{external:?}").contains(SECRET_CANARY));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ring_overflow_reports_gap_and_slow_subscribers_are_disconnected() {
|
||||||
|
let observer = Observability::memory(ObservabilityLimits {
|
||||||
|
ring_events: 3,
|
||||||
|
subscriber_queue: 1,
|
||||||
|
..limits()
|
||||||
|
})
|
||||||
|
.expect("observer");
|
||||||
|
for index in 0..5 {
|
||||||
|
observer
|
||||||
|
.record(draft(
|
||||||
|
EventFamily::ToolProgress,
|
||||||
|
&format!("request-{index}"),
|
||||||
|
))
|
||||||
|
.expect("record");
|
||||||
|
}
|
||||||
|
assert_eq!(observer.snapshot().len(), 3);
|
||||||
|
assert_eq!(observer.metrics().snapshot().dropped_ring_events, 2);
|
||||||
|
let mut replay = observer.subscribe(Some(0)).expect("subscription");
|
||||||
|
assert_eq!(replay.take_gap(), Some((1, 3)));
|
||||||
|
assert_eq!(replay.recv().await.expect("replay").event_id, 3);
|
||||||
|
|
||||||
|
let mut slow = observer.subscribe(None).expect("slow subscription");
|
||||||
|
for index in 5..9 {
|
||||||
|
observer
|
||||||
|
.record(draft(
|
||||||
|
EventFamily::ToolProgress,
|
||||||
|
&format!("request-{index}"),
|
||||||
|
))
|
||||||
|
.expect("record");
|
||||||
|
}
|
||||||
|
while slow.recv().await.is_some() {}
|
||||||
|
assert!(observer.metrics().snapshot().dropped_subscriber_events >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn journal_rotates_recovers_truncated_tail_and_replays_without_execution() {
|
||||||
|
let directory = temp_dir("journal");
|
||||||
|
let config = JournalConfig {
|
||||||
|
directory: directory.clone(),
|
||||||
|
max_segment_bytes: 700,
|
||||||
|
max_segments: 3,
|
||||||
|
max_total_bytes: 2_100,
|
||||||
|
sync_each_record: false,
|
||||||
|
};
|
||||||
|
let (observer, runtime) = Observability::journaled(
|
||||||
|
ObservabilityLimits {
|
||||||
|
max_event_bytes: 512,
|
||||||
|
..limits()
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
.expect("journal observer");
|
||||||
|
for index in 0..12 {
|
||||||
|
let family = if index == 0 {
|
||||||
|
EventFamily::LifecycleTransition
|
||||||
|
} else if index == 1 {
|
||||||
|
EventFamily::BehaviorTransition
|
||||||
|
} else {
|
||||||
|
EventFamily::ToolResult
|
||||||
|
};
|
||||||
|
let mut event = draft(family, &format!("request-{index}"));
|
||||||
|
if family == EventFamily::LifecycleTransition || family == EventFamily::BehaviorTransition {
|
||||||
|
event = event.code_field("to", "available").expect("state");
|
||||||
|
}
|
||||||
|
if family == EventFamily::ToolResult {
|
||||||
|
event = event
|
||||||
|
.correlation(CorrelationIds {
|
||||||
|
action_id: Some(format!("action-{index}")),
|
||||||
|
..CorrelationIds::default()
|
||||||
|
})
|
||||||
|
.expect("action");
|
||||||
|
}
|
||||||
|
observer.record(event).expect("journal event");
|
||||||
|
}
|
||||||
|
runtime.shutdown(&observer).await.expect("journal shutdown");
|
||||||
|
let last_before_restart = observer.snapshot().last().expect("last event").event_id;
|
||||||
|
let (restarted, restarted_runtime) = Observability::journaled(
|
||||||
|
ObservabilityLimits {
|
||||||
|
max_event_bytes: 512,
|
||||||
|
..limits()
|
||||||
|
},
|
||||||
|
JournalConfig {
|
||||||
|
directory: directory.clone(),
|
||||||
|
max_segment_bytes: 700,
|
||||||
|
max_segments: 3,
|
||||||
|
max_total_bytes: 2_100,
|
||||||
|
sync_each_record: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("restart journal");
|
||||||
|
let restarted_id = restarted
|
||||||
|
.record(draft(EventFamily::Shutdown, "restart"))
|
||||||
|
.expect("restart event");
|
||||||
|
assert!(restarted_id > last_before_restart);
|
||||||
|
restarted_runtime
|
||||||
|
.shutdown(&restarted)
|
||||||
|
.await
|
||||||
|
.expect("restart shutdown");
|
||||||
|
let active = directory.join("events-current.jsonl.tmp");
|
||||||
|
OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&active)
|
||||||
|
.expect("active")
|
||||||
|
.write_all(b"{\"schema_version\":1")
|
||||||
|
.expect("torn record");
|
||||||
|
let replay = replay_journal(&directory, 4_096, 64).expect("recover replay");
|
||||||
|
assert!(!replay.timeline.is_empty());
|
||||||
|
assert!(replay.tool_outcomes > 0);
|
||||||
|
assert!(replay.unavailable_private_content);
|
||||||
|
assert!(
|
||||||
|
replay
|
||||||
|
.actions
|
||||||
|
.values()
|
||||||
|
.all(|action| action.final_result.is_some())
|
||||||
|
);
|
||||||
|
// Three retained segments, one active file, and one durable ID reservation.
|
||||||
|
assert!(fs::read_dir(&directory).expect("directory").count() <= 5);
|
||||||
|
let journal_bytes = fs::read_dir(&directory)
|
||||||
|
.expect("directory")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|entry| {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
name.ends_with(".jsonl") || name == "events-current.jsonl.tmp"
|
||||||
|
})
|
||||||
|
.map(|entry| entry.metadata().expect("metadata").len())
|
||||||
|
.sum::<u64>();
|
||||||
|
assert!(journal_bytes <= 2_100);
|
||||||
|
fs::remove_dir_all(directory).expect("remove test directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metrics_have_fixed_fields_and_saturating_latency_summaries() {
|
||||||
|
let observer = Observability::memory(limits()).expect("observer");
|
||||||
|
let metrics = observer.metrics();
|
||||||
|
metrics.set_health(true, 3);
|
||||||
|
metrics.set_load(4, 5, 6, 7);
|
||||||
|
metrics.set_rate_limit(8, 9);
|
||||||
|
metrics.record_inference_latency(Duration::from_millis(11));
|
||||||
|
metrics.record_inference_latency(Duration::from_millis(13));
|
||||||
|
metrics.record_tool_latency(Duration::from_millis(17));
|
||||||
|
metrics.record_inference_outcome(OutcomeCode::Failed);
|
||||||
|
metrics.record_tool_outcome(OutcomeCode::Completed);
|
||||||
|
metrics.record_policy_outcome(OutcomeCode::Denied);
|
||||||
|
let snapshot = metrics.snapshot();
|
||||||
|
assert!(snapshot.ready);
|
||||||
|
assert_eq!(snapshot.reconnects, 3);
|
||||||
|
assert_eq!(snapshot.active_sessions, 4);
|
||||||
|
assert_eq!(snapshot.queue_capacity, 7);
|
||||||
|
assert_eq!(snapshot.inference_latency.samples, 2);
|
||||||
|
assert_eq!(snapshot.inference_latency.total_millis, 24);
|
||||||
|
assert_eq!(snapshot.inference_latency.maximum_millis, 13);
|
||||||
|
assert_eq!(snapshot.tool_latency.maximum_millis, 17);
|
||||||
|
assert_eq!(snapshot.inference_outcomes.failed, 1);
|
||||||
|
assert_eq!(snapshot.tool_outcomes.completed, 1);
|
||||||
|
assert_eq!(snapshot.policy_outcomes.denied, 1);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(snapshot)
|
||||||
|
.expect("metrics")
|
||||||
|
.as_object()
|
||||||
|
.expect("object")
|
||||||
|
.len(),
|
||||||
|
18
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_policy_audit_correlates_outcomes_without_exposing_inputs() {
|
||||||
|
let probe_observer = Observability::memory(limits()).expect("probe observer");
|
||||||
|
let probe = EventDraft::new(
|
||||||
|
EventFamily::ToolResult,
|
||||||
|
EventSeverity::Info,
|
||||||
|
"policy",
|
||||||
|
EventOrigin::Policy,
|
||||||
|
)
|
||||||
|
.and_then(|event| {
|
||||||
|
event.correlation(CorrelationIds {
|
||||||
|
session_id: Some(SECRET_CANARY.to_owned()),
|
||||||
|
request_id: Some(SECRET_CANARY.to_owned()),
|
||||||
|
action_id: Some("authorization-7".to_owned()),
|
||||||
|
..CorrelationIds::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(|event| event.result_code("completed"))
|
||||||
|
.and_then(|event| event.reason_code("allowed"))
|
||||||
|
.and_then(|event| event.identifier_field("tool", SECRET_CANARY))
|
||||||
|
.and_then(|event| event.code_field("disposition", "allowed"))
|
||||||
|
.and_then(|event| event.field("tool_calls", Value::from(1)))
|
||||||
|
.and_then(|event| event.field("movement_millimeters", Value::from(0)))
|
||||||
|
.and_then(|event| event.redacted("tool_arguments"))
|
||||||
|
.expect("policy event draft");
|
||||||
|
probe_observer.record(probe).expect("policy event record");
|
||||||
|
|
||||||
|
let observer = Observability::memory(limits()).expect("observer");
|
||||||
|
let memory = std::sync::Arc::new(MemoryPolicyAudit::new(8).expect("policy audit"));
|
||||||
|
let audit = UnifiedPolicyAudit::new(memory.clone(), observer.clone());
|
||||||
|
audit
|
||||||
|
.emit(PolicyAuditRecord {
|
||||||
|
recorded_unix_millis: 1,
|
||||||
|
authorization_id: Some(7),
|
||||||
|
approval_id: None,
|
||||||
|
origin: OriginClass::AuthorizedIm,
|
||||||
|
origin_avatar_id: None,
|
||||||
|
principal: BoundedText::<MAX_IDENTIFIER_BYTES>::new("principal", SECRET_CANARY)
|
||||||
|
.expect("principal"),
|
||||||
|
session_id: BoundedText::<MAX_IDENTIFIER_BYTES>::new("session", SECRET_CANARY)
|
||||||
|
.expect("session"),
|
||||||
|
correlation_id: BoundedText::<MAX_IDENTIFIER_BYTES>::new("correlation", SECRET_CANARY)
|
||||||
|
.expect("correlation"),
|
||||||
|
tool: BoundedText::<MAX_IDENTIFIER_BYTES>::new("tool", SECRET_CANARY).expect("tool"),
|
||||||
|
disposition: PolicyDisposition::Allowed,
|
||||||
|
reason: PolicyReasonCode::Allowed,
|
||||||
|
applied_budget: ResourceCost::one_call(),
|
||||||
|
arguments_hash: BoundedText::<64>::new("hash", SECRET_CANARY).expect("hash"),
|
||||||
|
final_outcome: PolicyFinalOutcome::Completed,
|
||||||
|
})
|
||||||
|
.expect("emit audit");
|
||||||
|
assert_eq!(memory.snapshot().len(), 1);
|
||||||
|
let rendered = serde_json::to_string(&observer.snapshot()).expect("events");
|
||||||
|
assert!(!rendered.contains(SECRET_CANARY));
|
||||||
|
let families = observer
|
||||||
|
.snapshot()
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.family.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
families.iter().any(|family| family == "tool_result"),
|
||||||
|
"{families:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(observer.metrics().snapshot().policy_outcomes.completed, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nonblocking_flood_remains_bounded_when_journal_queue_is_saturated() {
|
||||||
|
let directory = temp_dir("flood");
|
||||||
|
let flood_limits = ObservabilityLimits {
|
||||||
|
ring_events: 16,
|
||||||
|
subscriber_queue: 1,
|
||||||
|
max_subscribers: 1,
|
||||||
|
journal_queue: 1,
|
||||||
|
max_event_bytes: 4_096,
|
||||||
|
max_diagnostic_entries: 0,
|
||||||
|
};
|
||||||
|
let (observer, runtime) = Observability::journaled(
|
||||||
|
flood_limits,
|
||||||
|
JournalConfig {
|
||||||
|
directory: directory.clone(),
|
||||||
|
max_segment_bytes: 4_096,
|
||||||
|
max_segments: 2,
|
||||||
|
max_total_bytes: 8_192,
|
||||||
|
sync_each_record: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("journal observer");
|
||||||
|
let _slow = observer.subscribe(None).expect("subscriber");
|
||||||
|
for index in 0..10_000 {
|
||||||
|
observer
|
||||||
|
.record(draft(EventFamily::ToolProgress, &format!("flood-{index}")))
|
||||||
|
.expect("bounded record");
|
||||||
|
}
|
||||||
|
assert_eq!(observer.snapshot().len(), flood_limits.ring_events);
|
||||||
|
let snapshot = observer.metrics().snapshot();
|
||||||
|
assert_eq!(snapshot.recorded_events, 10_000);
|
||||||
|
assert!(snapshot.dropped_journal_events > 0);
|
||||||
|
assert!(snapshot.dropped_subscriber_events > 0);
|
||||||
|
runtime.shutdown(&observer).await.expect("shutdown");
|
||||||
|
fs::remove_dir_all(directory).expect("remove test directory");
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
|||||||
let mut files = Vec::with_capacity(20);
|
let mut files = Vec::with_capacity(20);
|
||||||
collect_rust_files(&source, &mut files);
|
collect_rust_files(&source, &mut files);
|
||||||
assert!(
|
assert!(
|
||||||
files.len() <= 24,
|
files.len() <= 26,
|
||||||
"source-file count needs a reviewed bound update"
|
"source-file count needs a reviewed bound update"
|
||||||
);
|
);
|
||||||
for path in files {
|
for path in files {
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ The JSON request envelope is stable and versioned. For example:
|
|||||||
{"version":1,"request_id":"health-1","request":{"method":"health"}}
|
{"version":1,"request_id":"health-1","request":{"method":"health"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Observers can call `health`, `runtime`, `list_sessions`,
|
Observers can call `health`, `metrics`, `runtime`, `list_sessions`,
|
||||||
`list_scheduled_jobs`, `list_pending_approvals`, `list_audit_events`, and
|
`list_scheduled_jobs`, `list_pending_approvals`, `list_audit_events`,
|
||||||
`subscribe_events`. Operators can additionally call `cancel_request`,
|
`list_observability_events`, and `subscribe_events`. Operators can additionally
|
||||||
`pause_autonomy`, `resume_autonomy`, `cancel_action`, `decide_approval`,
|
call `cancel_request`, `pause_autonomy`, `resume_autonomy`, `cancel_action`,
|
||||||
`force_reconnect`, `expire_conversation`, `set_roaming_job`,
|
`decide_approval`, `force_reconnect`, `expire_conversation`, `set_roaming_job`,
|
||||||
`inject_operator_message`, and `graceful_shutdown`. Cancellation is a mutation
|
`inject_operator_message`, and `graceful_shutdown`. Cancellation is a mutation
|
||||||
and is operator-only. List requests use an opaque numeric cursor and a page
|
and is operator-only. List requests use an opaque numeric cursor and a page
|
||||||
size of 1 through 100.
|
size of 1 through 100.
|
||||||
@@ -62,6 +62,9 @@ region and pose fields when known, behavior mode, control-queue utilization,
|
|||||||
and aggregate budget use. Conversation responses contain metadata only. Audit
|
and aggregate budget use. Conversation responses contain metadata only. Audit
|
||||||
and approval responses exclude arguments, prompt contents, credentials,
|
and approval responses exclude arguments, prompt contents, credentials,
|
||||||
authorization headers, capability URLs, model reasoning, and filesystem data.
|
authorization headers, capability URLs, model reasoning, and filesystem data.
|
||||||
|
Unified events use pseudonymous correlation IDs and fixed-cardinality metrics;
|
||||||
|
their schema, journal, redaction, and replay rules are documented in
|
||||||
|
[`grid-agent-observability.md`](grid-agent-observability.md).
|
||||||
`cancel_action` binds to the exact bounded action ID carried by behavior audit
|
`cancel_action` binds to the exact bounded action ID carried by behavior audit
|
||||||
observations; it can cancel a queued or executing embodied action without
|
observations; it can cancel a queued or executing embodied action without
|
||||||
preempting unrelated work. The built-in roaming job ID is `default-roaming`.
|
preempting unrelated work. The built-in roaming job ID is `default-roaming`.
|
||||||
|
|||||||
118
docs/grid-agent-observability.md
Normal file
118
docs/grid-agent-observability.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Grid-agent observability, audit, metrics, and replay
|
||||||
|
|
||||||
|
The grid agent records externally observable decisions and outcomes without
|
||||||
|
claiming access to private model reasoning. The stable JSON schema version is
|
||||||
|
`1`. `StructuredEvent` is the transport-neutral representation used by the
|
||||||
|
in-memory ring, optional JSONL journal, control API, and replay reader.
|
||||||
|
|
||||||
|
## Event contract
|
||||||
|
|
||||||
|
Every event has `schema_version`, monotonic `event_id`, `unix_millis`,
|
||||||
|
`severity`, `component`, `family`, `origin`, correlation IDs, optional duration,
|
||||||
|
retry count, result/reason codes, redaction flags, bounded scalar fields, and
|
||||||
|
forward-compatible extension fields. Version 1 families cover lifecycle and
|
||||||
|
behavior transitions; inbound/outbound messages; conversation creation/expiry;
|
||||||
|
inference requests/results; model action summaries; tool proposals,
|
||||||
|
policy/approval decisions and tool execution; scheduled jobs; control commands;
|
||||||
|
shutdown; and diagnostic envelopes.
|
||||||
|
|
||||||
|
One recorder assigns a total admission order. This preserves order for an
|
||||||
|
action and gives concurrent producers a deterministic recorded order, but does
|
||||||
|
not claim causal order between tasks before recorder admission. Correlation
|
||||||
|
values are stable SHA-256 pseudonyms, so an operator can join a timeline without
|
||||||
|
retaining original avatar, session, request, or action identifiers. Runtime tool
|
||||||
|
names are likewise pseudonymized. Readable state/reason/result values are
|
||||||
|
compile-time enumeration codes.
|
||||||
|
|
||||||
|
Later readers ignore unknown top-level and `fields` keys. The Rust reader
|
||||||
|
retains both across a decode/encode cycle.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"schema_version":1,"event_id":42,"unix_millis":1787025600000,"severity":"info","component":"policy","family":"tool_result","correlation":{"session_id":"id:83c1...","request_id":"id:bd10...","action_id":"id:11a0..."},"origin":"policy","duration_millis":18,"retry_count":0,"result_code":"completed","reason_code":"allowed","redaction_flags":["tool_arguments"],"fields":{"tool_calls":1}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Privacy boundary
|
||||||
|
|
||||||
|
The event builder cannot accept free-form string fields. Message/prompt bodies,
|
||||||
|
model responses, authorization headers, API keys, grid passwords, capability
|
||||||
|
and asset URLs, inventory payloads, and hidden chain-of-thought have no runtime
|
||||||
|
event representation. Debug output omits field and extension values. Control
|
||||||
|
messages retain operation, role, and bounded metadata only.
|
||||||
|
|
||||||
|
Diagnostic capture is explicit and bounded by `max_diagnostic_entries`. It
|
||||||
|
emits a warning envelope with part count, total bytes, and a digest; prompt and
|
||||||
|
response text is never retained. It carries `diagnostic_capture` and
|
||||||
|
`content_omitted` flags. Hashes and timing can still reveal equality, so enabling
|
||||||
|
capture remains a privacy decision.
|
||||||
|
|
||||||
|
## Memory, subscribers, and JSONL journal
|
||||||
|
|
||||||
|
`Observability::memory` creates a fixed ring. Eviction increments
|
||||||
|
`dropped_ring_events`. Each subscriber has a bounded queue. Resuming before
|
||||||
|
retained history reports an explicit missing interval. Slow subscribers are
|
||||||
|
disconnected and counted; producers never await them.
|
||||||
|
|
||||||
|
`Observability::journaled` adds an optional bounded background writer. Producers
|
||||||
|
use `try_send`, so slow disks cannot block chat, lifecycle, or control handling.
|
||||||
|
Queue loss increments `dropped_journal_events`. The active file is
|
||||||
|
`events-current.jsonl.tmp`; complete segments are atomically renamed to
|
||||||
|
`events-NNN.jsonl`. Segment count and combined active/archive bytes are bounded,
|
||||||
|
with oldest complete segments removed first. `sync_each_record` trades latency
|
||||||
|
for durability. `ObservabilityRuntime::shutdown` drains, flushes, and syncs.
|
||||||
|
|
||||||
|
A crash may tear only the final active record. Startup truncates that fragment
|
||||||
|
to the last newline. A single immutable reservation marker allocates the next
|
||||||
|
bounded ID range before events are admitted, so IDs observed by subscribers but
|
||||||
|
dropped by a saturated journal queue are not reused after restart. Malformed
|
||||||
|
complete records, oversized records, and exhausted ID ranges fail closed.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use metacrate_grid_agent::{JournalConfig, Observability, ObservabilityLimits};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
# async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (events, runtime) = Observability::journaled(
|
||||||
|
ObservabilityLimits::default(),
|
||||||
|
JournalConfig {
|
||||||
|
directory: PathBuf::from("data/grid-agent/audit"),
|
||||||
|
max_segment_bytes: 4 * 1024 * 1024,
|
||||||
|
max_segments: 8,
|
||||||
|
max_total_bytes: 32 * 1024 * 1024,
|
||||||
|
sync_each_record: false,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
runtime.shutdown(&events).await?;
|
||||||
|
# Ok(()) }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metrics and control views
|
||||||
|
|
||||||
|
Metrics use fixed atomics with no caller-defined labels or external telemetry.
|
||||||
|
The snapshot contains readiness, reconnects, active sessions/tasks, queue use,
|
||||||
|
inference/tool latency count/sum/max, fixed inference/tool/policy outcome
|
||||||
|
buckets, rate-limit use, recorded events, and ring/subscriber/journal drop
|
||||||
|
counters.
|
||||||
|
|
||||||
|
Control protocol v1 provides read-only `metrics` and paginated
|
||||||
|
`list_observability_events` requests in addition to the policy audit. Observer
|
||||||
|
and operator roles may read them; sensitive payloads cannot enter either view.
|
||||||
|
|
||||||
|
## Deterministic replay
|
||||||
|
|
||||||
|
`replay_journal` reads retained segments and the recovered active file with
|
||||||
|
explicit event-size/count limits. It rebuilds lifecycle and behavior state,
|
||||||
|
session counts, policy/tool outcomes, and per-action observable timelines.
|
||||||
|
Redaction flags set `unavailable_private_content`, making missing context clear.
|
||||||
|
|
||||||
|
Replay is data-only: it has no grid backend, LLM client, policy gateway, or tool
|
||||||
|
executor, so it cannot resend messages or repeat mutations. Rotation means it
|
||||||
|
reconstructs the retained window, not already expired history.
|
||||||
|
|
||||||
|
## Focused verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --locked -p metacrate-grid-agent --lib observability_tests
|
||||||
|
cargo test --locked -p metacrate-grid-agent --test dependency_policy
|
||||||
|
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
|
||||||
|
RUSTDOCFLAGS="-D warnings" cargo doc --locked -p metacrate-grid-agent --no-deps
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user