Implement conversation browser and safe deletion (#138)
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 19:49:42 +02:00
parent 360203d647
commit 096911a156
13 changed files with 1513 additions and 60 deletions

View File

@@ -172,7 +172,7 @@ impl PageRequest {
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub next_cursor: Option<u64>,
@@ -220,6 +220,66 @@ pub struct SessionMetadataView {
pub bytes: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversationStatusView {
Active,
Expired,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConversationFilterView {
pub avatar: Option<String>,
pub channel: Option<ConversationChannelView>,
pub status: Option<ConversationStatusView>,
pub active_from_unix_millis: Option<u64>,
pub active_to_unix_millis: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ConversationMetadataView {
pub session_id: String,
pub avatar_id: String,
pub channel: ConversationChannelView,
pub status: ConversationStatusView,
pub created_unix_millis: u64,
pub last_active_unix_millis: u64,
pub turns: usize,
pub bytes: usize,
pub compacted: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ConversationEntryView {
pub sequence: u64,
pub unix_millis: u64,
pub role: String,
pub record_kind: String,
pub trust: String,
pub text: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct TranscriptEntryView {
pub entry_id: String,
pub parent_id: Option<String>,
pub kind: String,
pub active: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ConversationDetailView {
pub conversation: ConversationMetadataView,
pub entries: Page<ConversationEntryView>,
pub mentra_agent_id: Option<String>,
pub profile_generation: Option<u64>,
pub active_transcript_entries: usize,
pub archived_transcript_entries: usize,
pub transcript_entries: Vec<TranscriptEntryView>,
pub transcript_busy: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ScheduledJobView {
pub job_id: String,
@@ -331,6 +391,8 @@ pub enum ControlPayload {
Metrics(MetricsSnapshot),
Runtime(RuntimeView),
Sessions(Page<SessionMetadataView>),
Conversations(Page<ConversationMetadataView>),
ConversationDetail(ConversationDetailView),
ScheduledJobs(Page<ScheduledJobView>),
PendingApprovals(Page<PendingApprovalView>),
AuditEvents(Page<AuditEventView>),
@@ -361,6 +423,17 @@ pub enum ControlRequest {
ListSessions {
page: PageRequest,
},
ListConversations {
page: PageRequest,
filter: ConversationFilterView,
},
GetConversation {
session_id: String,
page: PageRequest,
},
DeleteConversation {
session_id: String,
},
ListScheduledJobs {
page: PageRequest,
},
@@ -449,6 +522,8 @@ impl ControlRequest {
| Self::Metrics
| Self::Runtime
| Self::ListSessions { .. }
| Self::ListConversations { .. }
| Self::GetConversation { .. }
| Self::ListScheduledJobs { .. }
| Self::ListPendingApprovals { .. }
| Self::ListAuditEvents { .. }
@@ -470,6 +545,21 @@ impl ControlRequest {
| Self::ListAuditEvents { page }
| Self::ListObservabilityEvents { page }
| Self::ListMemoryAgents { page } => page.valid(),
Self::ListConversations { page, filter } => {
page.valid()
&& filter
.avatar
.as_ref()
.is_none_or(|value| valid_identifier(value))
&& filter
.active_from_unix_millis
.zip(filter.active_to_unix_millis)
.is_none_or(|(from, to)| from <= to)
}
Self::GetConversation { session_id, page } => {
valid_identifier(session_id) && page.valid()
}
Self::DeleteConversation { session_id } => valid_identifier(session_id),
Self::BrowseMemories {
mentra_agent_id,
cursor,
@@ -1888,6 +1978,11 @@ fn memory_filter_valid(filter: &MemoryFilterView) -> bool {
fn payload_valid(payload: &ControlPayload, maximum_frame_bytes: usize) -> bool {
let page_is_bounded = match payload {
ControlPayload::Sessions(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::Conversations(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::ConversationDetail(detail) => {
detail.entries.items.len() <= usize::from(MAX_PAGE_SIZE)
&& detail.transcript_entries.len() <= usize::from(MAX_PAGE_SIZE)
}
ControlPayload::ScheduledJobs(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::PendingApprovals(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::AuditEvents(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
@@ -1928,6 +2023,9 @@ fn request_name(request: &ControlRequest) -> &'static str {
ControlRequest::Metrics => "metrics",
ControlRequest::Runtime => "runtime",
ControlRequest::ListSessions { .. } => "list_sessions",
ControlRequest::ListConversations { .. } => "list_conversations",
ControlRequest::GetConversation { .. } => "get_conversation",
ControlRequest::DeleteConversation { .. } => "delete_conversation",
ControlRequest::ListScheduledJobs { .. } => "list_scheduled_jobs",
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
ControlRequest::ListAuditEvents { .. } => "list_audit_events",

View File

@@ -6,9 +6,11 @@ use crate::behavior::{BehaviorIngress, BehaviorMode};
use crate::control_plane::{
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlContext, ControlError, ControlErrorCode,
ControlFuture, ControlPayload, ControlRequest, ControlTarget, ConversationChannelView,
HealthView, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
MemoryHealthView, MemoryKindView, MemoryPageView, MemoryRecordView, MemorySortView, Page,
PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView,
ConversationDetailView, ConversationEntryView, ConversationFilterView,
ConversationMetadataView, ConversationStatusView, HealthView, MemoryAgentView,
MemoryCursorView, MemoryDetailView, MemoryFilterView, MemoryHealthView, MemoryKindView,
MemoryPageView, MemoryRecordView, MemorySortView, Page, PageRequest, PendingApprovalView,
RuntimeView, ScheduledJobView, SessionMetadataView, TranscriptEntryView,
};
use crate::conversation::{ConversationChannel, ConversationKey, ConversationStore};
use crate::observability::{
@@ -90,6 +92,7 @@ pub struct AgentControlTarget {
build: Mutex<Option<Arc<dyn crate::build::BuildControl>>>,
vision: Mutex<Option<Arc<dyn crate::vision::VisionControl>>>,
memory: Mutex<Option<Arc<crate::memory_admin::MentraMemoryAdmin>>>,
conversation_admin: Mutex<Option<Arc<crate::interaction::PolicyLlmResponder>>>,
behavior: BehaviorIngress,
commands: mpsc::Sender<RuntimeControlCommand>,
command_capacity: usize,
@@ -136,6 +139,7 @@ impl AgentControlTarget {
build: Mutex::new(None),
vision: Mutex::new(None),
memory: Mutex::new(None),
conversation_admin: Mutex::new(None),
behavior,
commands,
command_capacity,
@@ -165,6 +169,13 @@ impl AgentControlTarget {
*lock(&self.memory) = Some(memory);
}
pub fn attach_conversation_control(
&self,
responder: Arc<crate::interaction::PolicyLlmResponder>,
) {
*lock(&self.conversation_admin) = Some(responder);
}
pub fn update_session(&self, session: SessionStatus) {
let mut state = lock(&self.state);
state.session = session;
@@ -198,6 +209,113 @@ impl AgentControlTarget {
lock(&self.state).service_state = "stopping";
}
fn get_conversation(
&self,
context: &ControlContext,
session_id: &str,
page: &PageRequest,
) -> Result<ControlPayload, ControlError> {
require_operator(context)?;
let detail = self
.conversations
.operator_detail(session_id)
.ok_or_else(|| {
control_error(ControlErrorCode::NotFound, "conversation not found", false)
})?;
let offset = usize::try_from(page.cursor.unwrap_or(0)).unwrap_or(usize::MAX);
let entries = page_values(
page,
detail
.entries
.into_iter()
.map(|entry| ConversationEntryView {
sequence: entry.sequence,
unix_millis: entry.unix_millis,
role: entry.role.to_owned(),
record_kind: entry.kind.to_owned(),
trust: match entry.trust {
crate::conversation::MemoryTrust::Untrusted => "untrusted",
crate::conversation::MemoryTrust::TrustedOutput => "trusted_output",
}
.to_owned(),
text: entry.text,
})
.collect(),
);
let conversation_admin = { lock(&self.conversation_admin).clone() };
let transcript = if let Some(admin) = conversation_admin {
match admin.conversation_transcript(session_id, offset, usize::from(page.limit)) {
Ok(value) => Some(value),
Err(error) if error == "conversation transcript not found" => None,
Err(error) => return Err(memory_error(error)),
}
} else {
None
};
let transcript_busy = transcript.as_ref().is_some_and(|value| value.busy);
Ok(ControlPayload::ConversationDetail(ConversationDetailView {
conversation: conversation_metadata_view(&detail.metadata, detail.status, true),
entries,
mentra_agent_id: transcript
.as_ref()
.map(|value| value.mentra_agent_id.clone()),
profile_generation: transcript.as_ref().map(|value| value.profile_generation),
active_transcript_entries: transcript.as_ref().map_or(0, |value| value.active_entries),
archived_transcript_entries: transcript
.as_ref()
.map_or(0, |value| value.archived_entries),
transcript_entries: transcript.map_or_else(Vec::new, |value| {
value
.entries
.into_iter()
.map(|entry| TranscriptEntryView {
entry_id: entry.entry_id,
parent_id: entry.parent_id,
kind: entry.kind.to_owned(),
active: entry.active,
})
.collect()
}),
transcript_busy,
}))
}
async fn delete_conversation(
&self,
context: &ControlContext,
session_id: &str,
) -> Result<ControlPayload, ControlError> {
require_operator(context)?;
if self.conversations.operator_detail(session_id).is_none() {
return Err(control_error(
ControlErrorCode::NotFound,
"conversation not found",
false,
));
}
let conversation_admin = { lock(&self.conversation_admin).clone() };
if let Some(admin) = conversation_admin {
admin
.clear_conversation_transcript(session_id)
.await
.map_err(memory_error)?;
}
if !self.conversations.delete_session(session_id).map_err(|_| {
control_error(
ControlErrorCode::Internal,
"conversation deletion could not be persisted",
true,
)
})? {
return Err(control_error(
ControlErrorCode::NotFound,
"conversation not found",
false,
));
}
Ok(ControlPayload::Completed)
}
fn enqueue(
&self,
operation: &'static str,
@@ -315,6 +433,26 @@ impl AgentControlTarget {
.collect();
Ok(ControlPayload::Sessions(page_values(&page, values)))
}
ControlRequest::ListConversations { page, filter } => {
let operator = context.role == crate::control_plane::ControlRole::Operator;
let values = self
.conversations
.list_operator_metadata()
.into_iter()
.filter(|value| conversation_matches(value, &filter))
.map(|value| {
conversation_metadata_view(&value.metadata, value.status, operator)
})
.collect();
Ok(ControlPayload::Conversations(page_values(&page, values)))
}
ControlRequest::GetConversation { .. } | ControlRequest::DeleteConversation { .. } => {
Err(control_error(
ControlErrorCode::Internal,
"async conversation request reached synchronous runtime",
false,
))
}
ControlRequest::ListScheduledJobs { page } => {
let values = lock(&self.jobs)
.iter()
@@ -694,7 +832,15 @@ impl ControlTarget for AgentControlTarget {
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);
let result = match request {
ControlRequest::GetConversation { session_id, page } => {
self.get_conversation(&context, &session_id, &page)
}
ControlRequest::DeleteConversation { session_id } => {
self.delete_conversation(&context, &session_id).await
}
request => self.execute_now(&context, request),
};
if let Some(observability) = lock(&self.observability).as_ref() {
let (severity, result_code) = match &result {
Ok(_) => (EventSeverity::Info, "accepted"),
@@ -836,6 +982,8 @@ fn request_correlation(request: &ControlRequest) -> CorrelationIds {
Some(format!("approval-{approval_id}"))
}
ControlRequest::ForgetMemory { record_id, .. } => Some(record_id.clone()),
ControlRequest::GetConversation { session_id, .. }
| ControlRequest::DeleteConversation { session_id } => Some(session_id.clone()),
_ => None,
};
CorrelationIds {
@@ -850,6 +998,9 @@ const fn runtime_request_name(request: &ControlRequest) -> &'static str {
ControlRequest::Metrics => "metrics",
ControlRequest::Runtime => "runtime",
ControlRequest::ListSessions { .. } => "list_sessions",
ControlRequest::ListConversations { .. } => "list_conversations",
ControlRequest::GetConversation { .. } => "get_conversation",
ControlRequest::DeleteConversation { .. } => "delete_conversation",
ControlRequest::ListScheduledJobs { .. } => "list_scheduled_jobs",
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
@@ -910,6 +1061,67 @@ fn page_values<T>(request: &PageRequest, values: Vec<T>) -> Page<T> {
}
}
fn conversation_matches(
value: &crate::conversation::OperatorConversationMetadata,
filter: &ConversationFilterView,
) -> bool {
let metadata = &value.metadata;
filter.status.is_none_or(|status| {
status
== match value.status {
crate::conversation::ConversationStatus::Active => ConversationStatusView::Active,
crate::conversation::ConversationStatus::Expired => ConversationStatusView::Expired,
}
}) && filter
.avatar
.as_ref()
.is_none_or(|avatar| metadata.avatar_id.to_string().eq_ignore_ascii_case(avatar))
&& filter.channel.as_ref().is_none_or(|channel| match channel {
ConversationChannelView::PublicChat => {
metadata.channel == ConversationChannel::PublicChat
}
ConversationChannelView::DirectIm => metadata.channel == ConversationChannel::DirectIm,
})
&& filter
.active_from_unix_millis
.is_none_or(|from| metadata.last_active_unix_millis >= from)
&& filter
.active_to_unix_millis
.is_none_or(|to| metadata.last_active_unix_millis <= to)
}
fn conversation_metadata_view(
metadata: &crate::conversation::ConversationMetadata,
status: crate::conversation::ConversationStatus,
operator: bool,
) -> ConversationMetadataView {
ConversationMetadataView {
session_id: if operator {
metadata.session_id.as_str().to_owned()
} else {
pseudonymous_identifier(metadata.session_id.as_str())
},
avatar_id: if operator {
metadata.avatar_id.to_string()
} else {
pseudonymous_identifier(&metadata.avatar_id.to_string())
},
channel: match metadata.channel {
ConversationChannel::PublicChat => ConversationChannelView::PublicChat,
ConversationChannel::DirectIm => ConversationChannelView::DirectIm,
},
status: match status {
crate::conversation::ConversationStatus::Active => ConversationStatusView::Active,
crate::conversation::ConversationStatus::Expired => ConversationStatusView::Expired,
},
created_unix_millis: metadata.created_unix_millis,
last_active_unix_millis: metadata.last_active_unix_millis,
turns: metadata.turns,
bytes: metadata.bytes,
compacted: metadata.compacted,
}
}
const fn behavior_name(mode: BehaviorMode) -> &'static str {
match mode {
BehaviorMode::Offline => "offline",

View File

@@ -1,10 +1,14 @@
use crate::behavior::{BehaviorController, BehaviorError, EmbodimentFuture, EmbodimentSink};
use crate::control_plane::{
CONTROL_PROTOCOL_VERSION, ControlLimits, ControlPayload, ControlPlane, ControlRequest,
ControlRequestEnvelope, MemoryFilterView, MemorySortView, PageRequest,
ControlRequestEnvelope, ConversationFilterView, ConversationStatusView, MemoryFilterView,
MemorySortView, PageRequest,
};
use crate::control_runtime::{AgentControlTarget, RuntimeControlCommand};
use crate::conversation::{ConversationLimits, ConversationStore};
use crate::conversation::{
ConversationChannel, ConversationKey, ConversationLimits, ConversationStore,
MemoryRecord as ConversationRecord,
};
use crate::perception::WorldPosition;
use crate::policy::{MemoryPolicyAudit, PolicyAuditSink, PolicyGateway, PolicyLimits};
use crate::{
@@ -146,6 +150,38 @@ async fn production_target_projects_state_and_routes_real_mutations() {
let conversations = Arc::new(
ConversationStore::open(ConversationLimits::default(), None).expect("conversations"),
);
let conversation_id = conversations
.append(
ConversationKey::new(
UUID::new_with_u_int64(55).expect("avatar"),
ConversationChannel::DirectIm,
)
.expect("key"),
ConversationRecord::avatar_message("bounded operator transcript"),
)
.expect("conversation")
.session_id;
conversations
.append(
ConversationKey::new(
UUID::new_with_u_int64(55).expect("avatar"),
ConversationChannel::DirectIm,
)
.expect("key"),
ConversationRecord::agent_visible_response("bounded response"),
)
.expect("second turn");
let other_conversation_id = conversations
.append(
ConversationKey::new(
UUID::new_with_u_int64(56).expect("avatar"),
ConversationChannel::PublicChat,
)
.expect("key"),
ConversationRecord::avatar_message("other avatar transcript"),
)
.expect("other conversation")
.session_id;
let (target, mut commands) =
AgentControlTarget::new(conversations, policy, audit, behavior.ingress(), 8)
.expect("target");
@@ -226,6 +262,108 @@ async fn production_target_projects_state_and_routes_real_mutations() {
assert!(vision.active_capture().is_none(), "pause cancels capture");
let observer = plane.connect("observer-token").expect("observer");
let observer_conversations = observer
.request(envelope(
"observer-conversations",
ControlRequest::ListConversations {
page: PageRequest::default(),
filter: ConversationFilterView::default(),
},
))
.await;
let Ok(ControlPayload::Conversations(observer_conversations)) = observer_conversations.result
else {
panic!("observer conversation metadata")
};
assert_eq!(observer_conversations.items.len(), 2);
assert_ne!(
observer_conversations.items[0].session_id,
conversation_id.as_str()
);
assert!(
observer
.request(envelope(
"observer-conversation-detail",
ControlRequest::GetConversation {
session_id: conversation_id.as_str().into(),
page: PageRequest::default(),
},
))
.await
.result
.is_err()
);
assert!(
observer
.request(envelope(
"observer-conversation-delete",
ControlRequest::DeleteConversation {
session_id: conversation_id.as_str().into(),
},
))
.await
.result
.is_err()
);
let operator_conversations = operator
.request(envelope(
"operator-conversations",
ControlRequest::ListConversations {
page: PageRequest::default(),
filter: ConversationFilterView {
status: Some(ConversationStatusView::Active),
..ConversationFilterView::default()
},
},
))
.await;
assert!(matches!(
operator_conversations.result,
Ok(ControlPayload::Conversations(_))
));
let detail = operator
.request(envelope(
"operator-conversation-detail",
ControlRequest::GetConversation {
session_id: conversation_id.as_str().into(),
page: PageRequest {
cursor: None,
limit: 1,
},
},
))
.await;
let Ok(ControlPayload::ConversationDetail(detail)) = detail.result else {
panic!("operator conversation detail")
};
assert_eq!(detail.entries.items.len(), 1);
assert_eq!(detail.entries.next_cursor, Some(1));
assert_eq!(detail.entries.items[0].text, "bounded operator transcript");
assert!(
operator
.request(envelope(
"operator-conversation-delete",
ControlRequest::DeleteConversation {
session_id: conversation_id.as_str().into()
},
))
.await
.result
.is_ok()
);
assert!(
operator
.request(envelope(
"other-conversation-detail",
ControlRequest::GetConversation {
session_id: other_conversation_id.as_str().into(),
page: PageRequest::default(),
},
))
.await
.result
.is_ok()
);
let health = observer
.request(envelope("memory-health", ControlRequest::MemoryHealth))
.await;
@@ -327,9 +465,8 @@ async fn production_target_projects_state_and_routes_real_mutations() {
.any(|event| event.family == "control_command")
);
assert!(events.items.iter().all(|event| {
!serde_json::to_string(event)
.expect("event JSON")
.contains("operator-token")
let json = serde_json::to_string(event).expect("event JSON");
!json.contains("operator-token") && !json.contains("bounded operator transcript")
}));
behavior.shutdown().await.expect("shutdown behavior");
}

View File

@@ -30,6 +30,7 @@ const MAX_PERSISTED_BYTES: usize = 64 * 1024 * 1024;
const MAX_SUMMARY_BYTES: usize = 64 * 1024;
const RETAINED_SNAPSHOTS: usize = 2;
const MAX_MEMORY_EVENTS: usize = 8_192;
const MAX_RETIRED_SESSIONS: usize = 4_096;
/// A trust-separated conversation channel. Group and conference channels are
/// deliberately unrepresentable until their authority semantics are defined.
@@ -266,6 +267,7 @@ pub enum ConversationError {
IdentifierGeneration,
PersistenceUnavailable,
PersistenceTooLarge,
StaleSession,
Boundary(crate::types::BoundaryError),
}
@@ -284,6 +286,7 @@ impl fmt::Display for ConversationError {
Self::PersistenceTooLarge => {
formatter.write_str("conversation snapshot exceeds its storage bound")
}
Self::StaleSession => formatter.write_str("conversation session is no longer active"),
Self::Boundary(error) => {
write!(formatter, "conversation boundary rejected data: {error}")
}
@@ -316,6 +319,19 @@ pub struct ConversationMetadata {
pub last_active_unix_millis: u64,
pub turns: usize,
pub bytes: usize,
pub compacted: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConversationStatus {
Active,
Expired,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OperatorConversationMetadata {
pub metadata: ConversationMetadata,
pub status: ConversationStatus,
}
/// LLM-only projection for one exact UUID/channel key.
@@ -325,6 +341,23 @@ pub struct ConversationContext {
entries: Vec<ContextEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConversationEntryProjection {
pub sequence: u64,
pub unix_millis: u64,
pub role: &'static str,
pub kind: &'static str,
pub trust: MemoryTrust,
pub text: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConversationDetailProjection {
pub metadata: ConversationMetadata,
pub status: ConversationStatus,
pub entries: Vec<ConversationEntryProjection>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ContextEntry {
role: MessageRole,
@@ -420,12 +453,17 @@ impl StoredSession {
last_active_unix_millis: self.last_active_unix_millis,
turns: self.entries.len(),
bytes: self.bytes,
compacted: self
.entries
.iter()
.any(|entry| entry.kind == RecordKind::FactualSummary),
}
}
}
struct StoreState {
sessions: BTreeMap<ConversationKey, StoredSession>,
retired: VecDeque<ConversationMetadata>,
total_bytes: usize,
next_sequence: u64,
snapshot_generation: u64,
@@ -481,6 +519,7 @@ impl ConversationStore {
limits.validate()?;
let mut state = StoreState {
sessions: BTreeMap::new(),
retired: VecDeque::new(),
total_bytes: 0,
next_sequence: 1,
snapshot_generation: 0,
@@ -502,6 +541,24 @@ impl ConversationStore {
&self,
key: ConversationKey,
record: MemoryRecord,
) -> Result<MemoryUpdate, ConversationError> {
self.append_checked(key, None, record)
}
pub fn append_if_session(
&self,
key: ConversationKey,
session_id: &str,
record: MemoryRecord,
) -> Result<MemoryUpdate, ConversationError> {
self.append_checked(key, Some(session_id), record)
}
fn append_checked(
&self,
key: ConversationKey,
expected_session_id: Option<&str>,
record: MemoryRecord,
) -> Result<MemoryUpdate, ConversationError> {
if key.avatar_id == UUID::zero() {
return Err(ConversationError::InvalidAvatar);
@@ -528,6 +585,14 @@ impl ConversationStore {
let mut state = self.lock_state();
let event_start = state.events.len();
expire_idle_locked(&mut state, monotonic_now, None);
if expected_session_id.is_some_and(|expected| {
state
.sessions
.get(&key)
.is_none_or(|session| session.id.as_str() != expected)
}) {
return Err(ConversationError::StaleSession);
}
if !state.sessions.contains_key(&key) {
while state.sessions.len() >= self.limits.max_active_sessions {
evict_oldest(&mut state, MemoryReason::SessionLimitEviction, None);
@@ -627,6 +692,140 @@ impl ConversationStore {
.collect()
}
#[must_use]
pub fn list_operator_metadata(&self) -> Vec<OperatorConversationMetadata> {
let now = self.clock.monotonic_now();
let mut state = self.lock_state();
expire_idle_locked(&mut state, now, None);
let mut values = state
.sessions
.values()
.map(|session| OperatorConversationMetadata {
metadata: session.metadata(),
status: ConversationStatus::Active,
})
.chain(
state
.retired
.iter()
.cloned()
.map(|metadata| OperatorConversationMetadata {
metadata,
status: ConversationStatus::Expired,
}),
)
.collect::<Vec<_>>();
values.sort_by(|left, right| {
right
.metadata
.last_active_unix_millis
.cmp(&left.metadata.last_active_unix_millis)
.then_with(|| {
left.metadata
.session_id
.as_str()
.cmp(right.metadata.session_id.as_str())
})
});
values
}
pub fn operator_detail(&self, session_id: &str) -> Option<ConversationDetailProjection> {
let now = self.clock.monotonic_now();
let mut state = self.lock_state();
expire_idle_locked(&mut state, now, None);
state
.sessions
.values()
.find(|session| session.id.as_str() == session_id)
.map(|session| ConversationDetailProjection {
metadata: session.metadata(),
status: ConversationStatus::Active,
entries: session
.entries
.iter()
.map(|entry| ConversationEntryProjection {
sequence: entry.sequence,
unix_millis: entry.unix_millis,
role: match entry.role {
MessageRole::Avatar => "avatar",
MessageRole::Agent => "agent",
MessageRole::Tool => "tool",
MessageRole::System => "data",
},
kind: match entry.kind {
RecordKind::Message => "message",
RecordKind::ToolSummary => "tool_summary",
RecordKind::ActionResult => "action_result",
RecordKind::FactualSummary => "factual_summary",
},
trust: entry.trust,
text: entry.text.as_str().to_owned(),
})
.collect(),
})
.or_else(|| {
state
.retired
.iter()
.find(|metadata| metadata.session_id.as_str() == session_id)
.cloned()
.map(|metadata| ConversationDetailProjection {
metadata,
status: ConversationStatus::Expired,
entries: Vec::new(),
})
})
}
pub fn delete_session(&self, session_id: &str) -> Result<bool, ConversationError> {
let mut state = self.lock_state();
let Some(key) = state
.sessions
.iter()
.find_map(|(key, session)| (session.id.as_str() == session_id).then_some(*key))
else {
if let Some(index) = state
.retired
.iter()
.position(|metadata| metadata.session_id.as_str() == session_id)
{
let previous = state
.retired
.remove(index)
.ok_or(ConversationError::PersistenceUnavailable)?;
if let Some(persistence) = &self.persistence
&& let Err(error) = write_snapshot(&self.limits, persistence, &mut state)
{
state.retired.insert(index, previous);
let _ = write_snapshot(&self.limits, persistence, &mut state);
return Err(error);
}
return Ok(true);
}
return Ok(false);
};
let session = state
.sessions
.remove(&key)
.ok_or(ConversationError::PersistenceUnavailable)?;
state.total_bytes = state.total_bytes.saturating_sub(session.bytes);
push_event(
&mut state.events,
event_for(MemoryReason::OperatorDeleted, &session),
);
if let Some(persistence) = &self.persistence
&& let Err(error) = write_snapshot(&self.limits, persistence, &mut state)
{
state.total_bytes = state.total_bytes.saturating_add(session.bytes);
state.sessions.insert(key, session);
state.events.pop_back();
let _ = write_snapshot(&self.limits, persistence, &mut state);
return Err(error);
}
Ok(true)
}
pub fn delete(&self, key: ConversationKey) -> bool {
self.remove_operator(key, MemoryReason::OperatorDeleted)
}
@@ -661,6 +860,9 @@ impl ConversationStore {
return false;
};
state.total_bytes = state.total_bytes.saturating_sub(session.bytes);
if reason == MemoryReason::OperatorExpired {
retain_expired(&mut state, session.metadata());
}
push_event(&mut state.events, event_for(reason, &session));
true
}
@@ -801,6 +1003,7 @@ fn expire_idle_locked(state: &mut StoreState, now: Instant, only: Option<Convers
ConversationChannel::PublicChat => MemoryReason::PublicExpired,
ConversationChannel::DirectIm => MemoryReason::DirectImExpired,
};
retain_expired(state, session.metadata());
push_event(&mut state.events, event_for(reason, &session));
}
}
@@ -818,10 +1021,18 @@ fn evict_oldest(state: &mut StoreState, reason: MemoryReason, preserve: Option<C
&& let Some(session) = state.sessions.remove(&key)
{
state.total_bytes = state.total_bytes.saturating_sub(session.bytes);
retain_expired(state, session.metadata());
push_event(&mut state.events, event_for(reason, &session));
}
}
fn retain_expired(state: &mut StoreState, metadata: ConversationMetadata) {
if state.retired.len() == MAX_RETIRED_SESSIONS {
state.retired.pop_front();
}
state.retired.push_back(metadata);
}
fn event_for(reason: MemoryReason, session: &StoredSession) -> MemoryEvent {
MemoryEvent {
reason,
@@ -904,6 +1115,21 @@ struct Snapshot {
generation: u64,
next_sequence: u64,
sessions: Vec<SnapshotSession>,
#[serde(default)]
retired_sessions: Vec<SnapshotRetiredSession>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SnapshotRetiredSession {
session_id: String,
avatar_id: String,
channel: ConversationChannel,
created_unix_millis: u64,
last_active_unix_millis: u64,
turns: usize,
bytes: usize,
compacted: bool,
}
#[derive(Serialize, Deserialize)]
@@ -1021,6 +1247,7 @@ fn restore_snapshot(
let wall_now = unix_millis(clock.wall_now());
let mut restored = StoreState {
sessions: BTreeMap::new(),
retired: VecDeque::new(),
total_bytes: 0,
next_sequence: snapshot.next_sequence.max(1),
snapshot_generation: snapshot.generation,
@@ -1029,6 +1256,31 @@ fn restore_snapshot(
let mut restored_session_ids = BTreeSet::new();
let mut restored_sequences = BTreeSet::new();
let mut maximum_sequence = 0_u64;
if snapshot.retired_sessions.len() > MAX_RETIRED_SESSIONS {
return Err(ConversationError::PersistenceTooLarge);
}
for persisted in snapshot.retired_sessions {
let avatar_id = UUID::new_with_string(persisted.avatar_id)
.map_err(|_| ConversationError::InvalidAvatar)?;
let session_id = BoundedText::new("conversation.session_id", persisted.session_id)?;
if !restored_session_ids.insert(session_id.as_str().to_owned())
|| persisted.created_unix_millis > persisted.last_active_unix_millis
|| persisted.turns > MAX_CONVERSATION_MESSAGES
|| persisted.bytes > MAX_SESSION_BYTES
{
return Err(ConversationError::PersistenceUnavailable);
}
restored.retired.push_back(ConversationMetadata {
session_id,
avatar_id,
channel: persisted.channel,
created_unix_millis: persisted.created_unix_millis.min(wall_now),
last_active_unix_millis: persisted.last_active_unix_millis.min(wall_now),
turns: persisted.turns,
bytes: persisted.bytes,
compacted: persisted.compacted,
});
}
if snapshot.sessions.len() > MAX_ACTIVE_SESSIONS {
return Err(ConversationError::PersistenceTooLarge);
}
@@ -1037,15 +1289,38 @@ fn restore_snapshot(
.map_err(|_| ConversationError::InvalidAvatar)?;
let key = ConversationKey::new(avatar_id, persisted.channel)?;
let session_id = BoundedText::new("conversation.session_id", persisted.session_id)?;
let persisted_bytes = persisted
.entries
.iter()
.try_fold(0_usize, |total, entry| total.checked_add(entry.text.len()))
.ok_or(ConversationError::PersistenceTooLarge)?;
if restored.sessions.contains_key(&key)
|| !restored_session_ids.insert(session_id.as_str().to_owned())
|| persisted.created_unix_millis > persisted.last_active_unix_millis
|| persisted.entries.len() > MAX_CONVERSATION_MESSAGES
|| persisted_bytes > limits.max_session_bytes
{
return Err(ConversationError::PersistenceUnavailable);
}
let elapsed_millis = wall_now.saturating_sub(persisted.last_active_unix_millis);
let elapsed = Duration::from_millis(elapsed_millis);
if elapsed >= key.channel.inactivity_ttl() {
retain_expired(
&mut restored,
ConversationMetadata {
session_id,
avatar_id,
channel: key.channel,
created_unix_millis: persisted.created_unix_millis.min(wall_now),
last_active_unix_millis: persisted.last_active_unix_millis.min(wall_now),
turns: persisted.entries.len(),
bytes: persisted_bytes,
compacted: persisted
.entries
.iter()
.any(|entry| entry.kind == RecordKind::FactualSummary),
},
);
continue;
}
let last_active = monotonic_now.checked_sub(elapsed).unwrap_or(monotonic_now);
@@ -1059,9 +1334,6 @@ fn restore_snapshot(
bytes: 0,
tool_results: 0,
};
if persisted.entries.len() > MAX_CONVERSATION_MESSAGES {
return Err(ConversationError::PersistenceTooLarge);
}
let mut previous_sequence = 0_u64;
for entry in persisted.entries {
if entry.sequence == 0 || !restored_sequences.insert(entry.sequence) {
@@ -1183,6 +1455,20 @@ fn write_snapshot(
.collect(),
})
.collect(),
retired_sessions: state
.retired
.iter()
.map(|metadata| SnapshotRetiredSession {
session_id: metadata.session_id.as_str().to_owned(),
avatar_id: metadata.avatar_id.to_string(),
channel: metadata.channel,
created_unix_millis: metadata.created_unix_millis,
last_active_unix_millis: metadata.last_active_unix_millis,
turns: metadata.turns,
bytes: metadata.bytes,
compacted: metadata.compacted,
})
.collect(),
};
let bytes =
serde_json::to_vec(&snapshot).map_err(|_| ConversationError::PersistenceUnavailable)?;

View File

@@ -509,6 +509,79 @@ fn operator_metadata_delete_and_expire_never_require_content_access() {
assert!(reasons.contains(&MemoryReason::OperatorExpired));
}
#[test]
fn expired_metadata_and_exact_delete_survive_restart_without_touching_other_sessions() {
let directory = temporary_directory();
let persistence = ConversationPersistence {
directory: directory.clone(),
};
let store = ConversationStore::open(limits(), Some(persistence.clone())).unwrap();
let expired = key(70, ConversationChannel::PublicChat);
let retained = key(71, ConversationChannel::DirectIm);
let expired_id = store
.append(expired, MemoryRecord::avatar_message("old"))
.unwrap()
.session_id;
let retained_id = store
.append(retained, MemoryRecord::avatar_message("keep"))
.unwrap()
.session_id;
assert!(store.expire(expired));
store.flush().unwrap();
drop(store);
let reopened = ConversationStore::open(limits(), Some(persistence)).unwrap();
let rows = reopened.list_operator_metadata();
assert!(
rows.iter().any(|row| row.metadata.session_id == expired_id
&& row.status == ConversationStatus::Expired)
);
assert!(
rows.iter().any(|row| row.metadata.session_id == retained_id
&& row.status == ConversationStatus::Active)
);
assert!(reopened.delete_session(expired_id.as_str()).unwrap());
assert!(reopened.context(retained).is_some());
drop(reopened);
let reopened = ConversationStore::open(
limits(),
Some(ConversationPersistence {
directory: directory.clone(),
}),
)
.unwrap();
assert!(
!reopened
.list_operator_metadata()
.iter()
.any(|row| row.metadata.session_id == expired_id)
);
assert!(reopened.context(retained).is_some());
drop(reopened);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn stale_completion_cannot_recreate_a_deleted_session() {
let store = ConversationStore::open(limits(), None).unwrap();
let conversation = key(72, ConversationChannel::DirectIm);
let session_id = store
.append(conversation, MemoryRecord::avatar_message("request"))
.unwrap()
.session_id;
assert!(store.delete_session(session_id.as_str()).unwrap());
assert_eq!(
store.append_if_session(
conversation,
session_id.as_str(),
MemoryRecord::agent_visible_response("late")
),
Err(ConversationError::StaleSession)
);
assert!(store.list_metadata().is_empty());
}
#[cfg(unix)]
#[test]
fn persistence_uses_restrictive_unix_permissions() {

View File

@@ -1228,8 +1228,9 @@ async fn process_batch(
)
.await;
let outcome = if delivered == parts.len() {
let _ = conversation.append(
let _ = conversation.append_if_session(
conversation_key,
update.session_id.as_str(),
MemoryRecord::agent_visible_response(safe.clone()),
);
DeliveryOutcome::Succeeded
@@ -1781,6 +1782,7 @@ pub struct PolicyLlmResponder {
runtime: Arc<mentra::Runtime>,
agents: Mutex<BTreeMap<String, MentraAgentHandle>>,
memory_admin: Arc<crate::memory_admin::MentraMemoryAdmin>,
conversation_mappings: Arc<crate::memory_admin::MentraConversationMappings>,
active_tools: Arc<Mutex<BTreeMap<String, ActiveMentraRequest>>>,
gateway: Arc<crate::policy::PolicyGateway>,
backend: Arc<dyn crate::backend::AuthorizedToolBackend>,
@@ -1789,6 +1791,24 @@ pub struct PolicyLlmResponder {
storage_path: std::path::PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MentraTranscriptEntry {
pub entry_id: String,
pub parent_id: Option<String>,
pub kind: &'static str,
pub active: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MentraTranscriptMetadata {
pub mentra_agent_id: String,
pub profile_generation: u64,
pub active_entries: usize,
pub archived_entries: usize,
pub entries: Vec<MentraTranscriptEntry>,
pub busy: bool,
}
#[derive(Clone)]
struct MentraAgentHandle {
id: String,
@@ -1800,6 +1820,7 @@ struct ActiveMentraRequest {
executor: Arc<crate::policy::PolicyToolExecutor>,
cancellation: CancellationToken,
mentra_cancellation: mentra::runtime::CancellationToken,
session_id: String,
}
struct AutonomousApprovalReviewer {
@@ -2009,6 +2030,12 @@ impl PolicyLlmResponder {
)
.map_err(InteractionError::Persistence)?,
);
let conversation_mappings = Arc::new(
crate::memory_admin::MentraConversationMappings::new(
storage_path.join("conversation-agents.json"),
)
.map_err(InteractionError::Persistence)?,
);
let mut builder = mentra::Runtime::builder()
.with_runtime_identifier(MENTRA_RUNTIME_IDENTIFIER)
.with_store(store)
@@ -2047,6 +2074,7 @@ impl PolicyLlmResponder {
runtime: Arc::new(runtime),
agents: Mutex::new(agents),
memory_admin,
conversation_mappings,
active_tools,
gateway,
backend,
@@ -2072,6 +2100,7 @@ impl PolicyLlmResponder {
.register(&request.sender_id.to_string(), &key, &handle.id)
.map_err(|_| InteractionModelError::Failed)?;
}
self.register_conversation(request, &key, &handle.id)?;
return Ok(Arc::clone(&handle.agent));
}
let tools = self.gateway.tools_for(context, (self.now)());
@@ -2118,13 +2147,146 @@ impl PolicyLlmResponder {
.register(&request.sender_id.to_string(), &key, &id)
.map_err(|_| InteractionModelError::Failed)?;
}
self.register_conversation(request, &key, &id)?;
Ok(agent)
}
fn register_conversation(
&self,
request: &ResponseRequest,
logical_agent_id: &str,
mentra_agent_id: &str,
) -> Result<(), InteractionModelError> {
let profile_generation = self
.conversation_mappings
.profile_generation(request.session_id.as_str(), logical_agent_id);
self.conversation_mappings
.register(crate::memory_admin::ConversationAgentMapping {
session_id: request.session_id.as_str().to_owned(),
avatar_id: request.sender_id.to_string(),
channel: match request.channel {
InteractionChannel::PublicChat => "public_chat",
InteractionChannel::DirectIm => "direct_im",
}
.to_owned(),
logical_agent_id: logical_agent_id.to_owned(),
mentra_agent_id: mentra_agent_id.to_owned(),
profile_generation,
})
.map_err(|_| InteractionModelError::Failed)
}
#[must_use]
pub fn memory_admin(&self) -> Arc<crate::memory_admin::MentraMemoryAdmin> {
Arc::clone(&self.memory_admin)
}
pub fn conversation_transcript(
&self,
session_id: &str,
offset: usize,
limit: usize,
) -> Result<MentraTranscriptMetadata, String> {
let mapping = self
.conversation_mappings
.get(session_id)
.ok_or_else(|| "conversation transcript not found".to_owned())?;
let agent = {
let agents = self
.agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
agents
.values()
.find(|handle| handle.id == mapping.mentra_agent_id)
.map(|handle| Arc::clone(&handle.agent))
.ok_or_else(|| "conversation agent not active".to_owned())?
};
let Ok(agent) = agent.try_lock() else {
return Ok(MentraTranscriptMetadata {
mentra_agent_id: mapping.mentra_agent_id,
profile_generation: mapping.profile_generation,
active_entries: 0,
archived_entries: 0,
entries: Vec::new(),
busy: true,
});
};
let transcript = agent.transcript();
let active_entries = transcript.items().len();
let archived_entries = transcript.archived().len();
let entries = transcript
.items()
.iter()
.map(|item| (item, true))
.chain(transcript.archived().iter().map(|item| (item, false)))
.skip(offset)
.take(limit)
.map(|(item, active)| MentraTranscriptEntry {
entry_id: item.id.to_string(),
parent_id: item.parent_id.as_ref().map(ToString::to_string),
kind: transcript_kind_name(&item.kind),
active,
})
.collect();
Ok(MentraTranscriptMetadata {
mentra_agent_id: mapping.mentra_agent_id,
profile_generation: mapping.profile_generation,
active_entries,
archived_entries,
entries,
busy: false,
})
}
pub async fn clear_conversation_transcript(&self, session_id: &str) -> Result<bool, String> {
let Some(mapping) = self.conversation_mappings.get(session_id) else {
return Ok(false);
};
if let Some(active) = self
.active_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&mapping.mentra_agent_id)
.cloned()
{
if active.session_id != session_id {
return Err("conversation agent is serving a different session".to_owned());
}
active.mentra_cancellation.cancel();
}
let agent = {
let agents = self
.agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
agents
.values()
.find(|handle| handle.id == mapping.mentra_agent_id)
.map(|handle| Arc::clone(&handle.agent))
.ok_or_else(|| "conversation agent not active".to_owned())?
};
agent
.lock()
.await
.clear_transcript()
.map_err(|error| error.to_string())?;
self.conversation_mappings.remove(session_id)?;
Ok(true)
}
}
fn transcript_kind_name(kind: &mentra::TranscriptKind) -> &'static str {
match kind {
mentra::TranscriptKind::UserTurn => "user_turn",
mentra::TranscriptKind::AssistantTurn => "assistant_turn",
mentra::TranscriptKind::ToolExchange { .. } => "tool_exchange",
mentra::TranscriptKind::CanonicalContext => "canonical_context",
mentra::TranscriptKind::MemoryRecall => "memory_recall",
mentra::TranscriptKind::DelegationRequest { .. } => "delegation_request",
mentra::TranscriptKind::DelegationResult { .. } => "delegation_result",
mentra::TranscriptKind::CompactionSummary { .. } => "compaction_summary",
}
}
pub(crate) fn mentra_agent_name(request: &ResponseRequest) -> String {
@@ -2196,6 +2358,7 @@ impl InteractionResponder for PolicyLlmResponder {
executor,
cancellation: cancellation.clone(),
mentra_cancellation: mentra_cancellation.clone(),
session_id: request.session_id.as_str().to_owned(),
},
);
let cancellation_bridge = {

View File

@@ -96,27 +96,31 @@ pub use control_plane::{
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlContext, ControlError, ControlErrorBody,
ControlErrorCode, ControlEvent, ControlEventKind, ControlFuture, ControlLimits, ControlPayload,
ControlPlane, ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, ControlRole,
ControlSubscription, ControlTarget, ConversationChannelView, HealthView,
InProcessControlClient, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
MemoryHealthView, MemoryKindView, MemoryPageView, MemoryRecordView, MemorySortView, Page,
PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView,
TcpControlClient, TcpControlConfig, TcpControlServer,
ControlSubscription, ControlTarget, ConversationChannelView, ConversationDetailView,
ConversationEntryView, ConversationFilterView, ConversationMetadataView,
ConversationStatusView, HealthView, InProcessControlClient, MemoryAgentView, MemoryCursorView,
MemoryDetailView, MemoryFilterView, MemoryHealthView, MemoryKindView, MemoryPageView,
MemoryRecordView, MemorySortView, Page, PageRequest, PendingApprovalView, RuntimeView,
ScheduledJobView, SessionMetadataView, TcpControlClient, TcpControlConfig, TcpControlServer,
TranscriptEntryView,
};
pub use control_runtime::{AgentControlTarget, RuntimeControlCommand};
pub use conversation::{
ConversationChannel, ConversationClock, ConversationContext, ConversationError,
ConversationKey, ConversationLimits, ConversationMetadata, ConversationPersistence,
ConversationStore, MemoryEvent, MemoryReason, MemoryRecord, MemoryTrust, MemoryUpdate,
SystemConversationClock,
ConversationChannel, ConversationClock, ConversationContext, ConversationDetailProjection,
ConversationEntryProjection, ConversationError, ConversationKey, ConversationLimits,
ConversationMetadata, ConversationPersistence, ConversationStatus, ConversationStore,
MemoryEvent, MemoryReason, MemoryRecord, MemoryTrust, MemoryUpdate,
OperatorConversationMetadata, SystemConversationClock,
};
pub use interaction::{
DeliveryFuture, DeliveryOutcome, ImDialogKind, InboundInteraction, InboundSource,
InferenceOutcome, InteractionChannel, InteractionCoordinator, InteractionDeliveryError,
InteractionError, InteractionHandle, InteractionIngress, InteractionIntent,
InteractionModelError, InteractionObservation, InteractionOrigin, InteractionPacingError,
InteractionResponder, InteractionSettings, InteractionSink, OutboundInteraction, PacerFuture,
PolicyLlmResponder, ResponderFuture, ResponsePacer, ResponseRequest, SuppressionReason,
VisibleResponse, split_utf8,
InteractionResponder, InteractionSettings, InteractionSink, MentraTranscriptEntry,
MentraTranscriptMetadata, OutboundInteraction, PacerFuture, PolicyLlmResponder,
ResponderFuture, ResponsePacer, ResponseRequest, SuppressionReason, VisibleResponse,
split_utf8,
};
pub use landmarks::{
LANDMARK_CREATE_TOOL, LANDMARK_LIST_TOOL, LANDMARK_SCHEDULE_TOOL, LANDMARK_STATUS_TOOL,
@@ -133,7 +137,9 @@ pub use landmarks::{
pub use llm::{
CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError, ToolDefinition, ToolSchema,
};
pub use memory_admin::{MemoryAgentMapping, MentraMemoryAdmin};
pub use memory_admin::{
ConversationAgentMapping, MemoryAgentMapping, MentraConversationMappings, MentraMemoryAdmin,
};
pub use observability::{
AgentMetrics, CorrelationIds, DiagnosticDirection, EventDraft, EventFamily, EventOrigin,
EventSeverity, EventSubscription, JournalConfig, LatencyMetrics, MetricsSnapshot,

View File

@@ -416,6 +416,7 @@ async fn run_live(
control_target.attach_build_control(live.build_control.clone());
control_target.attach_vision_control(live.vision.clone());
control_target.attach_memory_control(live.memory_admin.clone());
control_target.attach_conversation_control(live.conversation_admin.clone());
control_target.update_session(handle.status());
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
let (control_plane, integrated_client, control_server) = match config.mode {
@@ -734,6 +735,7 @@ struct LiveInteractions {
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
observability: Arc<metacrate_grid_agent::Observability>,
memory_admin: Arc<metacrate_grid_agent::MentraMemoryAdmin>,
conversation_admin: Arc<metacrate_grid_agent::PolicyLlmResponder>,
_appearance_recovery: libremetaverse_types::compat::Subscription,
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
@@ -906,6 +908,7 @@ async fn start_live_interactions(
config.storage_path.join("mentra"),
)?);
let memory_admin = responder.memory_admin();
let conversation_admin = responder.clone();
let vision_limits = config.vision;
let scene_source = Arc::new(LibremetaverseSceneSource::new(owner, vision_limits));
scene_source.start_prefetch();
@@ -940,6 +943,7 @@ async fn start_live_interactions(
audit,
observability,
memory_admin,
conversation_admin,
_appearance_recovery: appearance_recovery,
_landmark_intake: landmark_intake,
landmark_roaming,

View File

@@ -13,6 +13,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
const MAX_MAPPINGS: usize = 4_096;
const MAX_MAPPING_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MemoryAgentMapping {
@@ -21,6 +22,100 @@ pub struct MemoryAgentMapping {
pub mentra_agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ConversationAgentMapping {
pub session_id: String,
pub avatar_id: String,
pub channel: String,
pub logical_agent_id: String,
pub mentra_agent_id: String,
pub profile_generation: u64,
}
pub struct MentraConversationMappings {
path: PathBuf,
mappings: Mutex<BTreeMap<String, ConversationAgentMapping>>,
}
impl MentraConversationMappings {
pub fn new(path: PathBuf) -> Result<Self, String> {
let values: Vec<ConversationAgentMapping> = load_values(&path)?;
if values.len() > MAX_MAPPINGS {
return Err("excessive conversation-agent mappings".to_owned());
}
let mut mappings = BTreeMap::new();
for mapping in values {
if !valid_conversation_mapping(&mapping)
|| mappings
.insert(mapping.session_id.clone(), mapping)
.is_some()
{
return Err("invalid or duplicate conversation-agent mapping".to_owned());
}
}
Ok(Self {
path,
mappings: Mutex::new(mappings),
})
}
pub fn register(&self, mapping: ConversationAgentMapping) -> Result<(), String> {
if !valid_conversation_mapping(&mapping) {
return Err("invalid conversation-agent mapping".to_owned());
}
let mut mappings = lock(&self.mappings);
if !mappings.contains_key(&mapping.session_id) && mappings.len() >= MAX_MAPPINGS {
return Err("conversation-agent mapping limit reached".to_owned());
}
if mappings.get(&mapping.session_id) == Some(&mapping) {
return Ok(());
}
let key = mapping.session_id.clone();
let previous = mappings.insert(key.clone(), mapping);
if let Err(error) = persist_values(&self.path, mappings.values()) {
if let Some(previous) = previous {
mappings.insert(key, previous);
} else {
mappings.remove(&key);
}
return Err(error);
}
Ok(())
}
pub fn get(&self, session_id: &str) -> Option<ConversationAgentMapping> {
lock(&self.mappings).get(session_id).cloned()
}
pub fn profile_generation(&self, session_id: &str, logical_agent_id: &str) -> u64 {
let mappings = lock(&self.mappings);
mappings.get(session_id).map_or_else(
|| {
mappings
.values()
.filter(|mapping| mapping.logical_agent_id == logical_agent_id)
.map(|mapping| mapping.profile_generation)
.max()
.unwrap_or(0)
.saturating_add(1)
},
|mapping| mapping.profile_generation,
)
}
pub fn remove(&self, session_id: &str) -> Result<bool, String> {
let mut mappings = lock(&self.mappings);
let Some(previous) = mappings.remove(session_id) else {
return Ok(false);
};
if let Err(error) = persist_values(&self.path, mappings.values()) {
mappings.insert(session_id.to_owned(), previous);
return Err(error);
}
Ok(true)
}
}
pub struct MentraMemoryAdmin {
store: HybridRuntimeStore,
mappings_path: PathBuf,
@@ -160,21 +255,7 @@ impl MentraMemoryAdmin {
}
fn load_mappings(path: &Path) -> Result<BTreeMap<String, MemoryAgentMapping>, String> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match std::fs::read(path.with_extension("json.bak")) {
Ok(bytes) => bytes,
Err(backup) if backup.kind() == std::io::ErrorKind::NotFound => {
return Ok(BTreeMap::new());
}
Err(backup) => return Err(backup.to_string()),
}
}
Err(error) => return Err(error.to_string()),
};
let values: Vec<MemoryAgentMapping> =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let values: Vec<MemoryAgentMapping> = load_values(path)?;
if values.len() > MAX_MAPPINGS || values.iter().any(|mapping| !valid_mapping(mapping)) {
return Err("invalid or excessive memory-agent mappings".to_owned());
}
@@ -190,6 +271,33 @@ fn load_mappings(path: &Path) -> Result<BTreeMap<String, MemoryAgentMapping>, St
Ok(mappings)
}
fn load_values<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Vec<T>, String> {
let bytes = match read_mapping_file(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match read_mapping_file(&path.with_extension("json.bak")) {
Ok(bytes) => bytes,
Err(backup) if backup.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(backup) => return Err(backup.to_string()),
}
}
Err(error) => return Err(error.to_string()),
};
serde_json::from_slice(&bytes).map_err(|error| error.to_string())
}
fn read_mapping_file(path: &Path) -> std::io::Result<Vec<u8>> {
if std::fs::metadata(path)?.len() > MAX_MAPPING_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"mapping file exceeds its bound",
));
}
std::fs::read(path)
}
fn valid_mapping(mapping: &MemoryAgentMapping) -> bool {
[
mapping.avatar_id.as_str(),
@@ -203,6 +311,13 @@ fn valid_mapping(mapping: &MemoryAgentMapping) -> bool {
fn persist_mappings<'a>(
path: &Path,
mappings: impl Iterator<Item = &'a MemoryAgentMapping>,
) -> Result<(), String> {
persist_values(path, mappings)
}
fn persist_values<'a, T: Serialize + 'a>(
path: &Path,
mappings: impl Iterator<Item = &'a T>,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
@@ -229,6 +344,20 @@ fn persist_mappings<'a>(
Ok(())
}
fn valid_conversation_mapping(mapping: &ConversationAgentMapping) -> bool {
mapping.profile_generation > 0
&& [
mapping.session_id.as_str(),
mapping.avatar_id.as_str(),
mapping.channel.as_str(),
mapping.logical_agent_id.as_str(),
mapping.mentra_agent_id.as_str(),
]
.iter()
.all(|value| !value.is_empty() && value.len() <= 256 && !value.contains('\0'))
&& matches!(mapping.channel.as_str(), "public_chat" | "direct_im")
}
fn lock<T>(value: &Mutex<T>) -> MutexGuard<'_, T> {
value
.lock()
@@ -271,6 +400,29 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn conversation_mapping_is_explicit_persisted_and_exactly_removed() {
let root =
std::env::temp_dir().join(format!("metacrate-conversation-map-{}", std::process::id()));
let path = root.join("conversation-agents.json");
let mappings = MentraConversationMappings::new(path.clone()).unwrap();
let mapping = ConversationAgentMapping {
session_id: "session-a".into(),
avatar_id: "avatar-a".into(),
channel: "direct_im".into(),
logical_agent_id: "logical-a".into(),
mentra_agent_id: "mentra-a".into(),
profile_generation: 1,
};
mappings.register(mapping.clone()).unwrap();
drop(mappings);
let reopened = MentraConversationMappings::new(path).unwrap();
assert_eq!(reopened.get("session-a"), Some(mapping));
assert!(reopened.remove("session-a").unwrap());
assert!(reopened.get("session-a").is_none());
let _ = std::fs::remove_dir_all(root);
}
fn record(id: &str, agent_id: &str) -> MemoryRecord {
MemoryRecord {
record_id: id.to_owned(),

View File

@@ -7,7 +7,8 @@
use crate::control_plane::{
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlEvent, ControlEventKind, ControlPayload,
ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView,
ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, ConversationDetailView,
ConversationFilterView, ConversationMetadataView, ConversationStatusView, HealthView,
InProcessControlClient, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
MemoryHealthView, MemoryKindView, MemoryRecordView, MemorySortView, Page, PageRequest,
PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView, TcpControlClient,
@@ -339,6 +340,8 @@ pub struct OperatorSnapshot {
pub runtime: Option<RuntimeView>,
pub metrics: Option<MetricsSnapshot>,
pub sessions: Vec<SessionMetadataView>,
pub conversations: Vec<ConversationMetadataView>,
pub conversation_detail: Option<ConversationDetailView>,
pub schedules: Vec<ScheduledJobView>,
pub approvals: Vec<PendingApprovalView>,
pub memory_health: Option<MemoryHealthView>,
@@ -358,6 +361,8 @@ impl Default for OperatorSnapshot {
runtime: None,
metrics: None,
sessions: Vec::new(),
conversations: Vec::new(),
conversation_detail: None,
schedules: Vec::new(),
approvals: Vec::new(),
memory_health: None,
@@ -397,6 +402,9 @@ pub enum OperatorCommand {
avatar_id: String,
direct_im: bool,
},
DeleteConversation {
session_id: String,
},
ToggleSchedule {
job_id: String,
enabled: bool,
@@ -432,6 +440,9 @@ impl OperatorCommand {
crate::control_plane::ConversationChannelView::PublicChat
},
},
Self::DeleteConversation { session_id } => ControlRequest::DeleteConversation {
session_id: session_id.clone(),
},
Self::ToggleSchedule { job_id, enabled } => ControlRequest::SetRoamingJob {
job_id: job_id.clone(),
enabled: *enabled,
@@ -456,7 +467,9 @@ impl OperatorCommand {
| Self::Reconnect
| Self::ExpireSession { .. }
| Self::ToggleSchedule { .. } => CommandConfirmation::Normal,
Self::ForgetMemory { .. } | Self::Shutdown => CommandConfirmation::HighRisk,
Self::ForgetMemory { .. } | Self::DeleteConversation { .. } | Self::Shutdown => {
CommandConfirmation::HighRisk
}
}
}
}
@@ -491,6 +504,10 @@ pub enum TuiInput {
FilterBackspace,
FilterCommit,
FilterCancel,
ConversationLoad,
ConversationClose,
ConversationNextPage,
ConversationPreviousPage,
MemoryLoad,
MemoryNextAgent,
MemoryNextPage,
@@ -653,6 +670,10 @@ pub struct OperatorTui {
memory_current_offset: u64,
memory_next_offset: Option<u64>,
memory_refresh_pending: bool,
conversation_cursors: Vec<u64>,
conversation_next_cursor: Option<u64>,
conversation_detail_cursor: u64,
conversation_detail_next: Option<u64>,
}
impl Default for OperatorTui {
@@ -683,6 +704,10 @@ impl Default for OperatorTui {
memory_current_offset: 0,
memory_next_offset: None,
memory_refresh_pending: false,
conversation_cursors: Vec::new(),
conversation_next_cursor: None,
conversation_detail_cursor: 0,
conversation_detail_next: None,
}
}
}
@@ -752,9 +777,45 @@ impl OperatorTui {
self.filter.query = query.join(" ");
}
fn conversation_request(&self) -> ControlRequest {
let mut filter = ConversationFilterView::default();
for token in self.filter.query.split_whitespace() {
if let Some(value) = token.strip_prefix("avatar:") {
filter.avatar = (!value.is_empty()).then(|| value.to_owned());
} else if let Some(value) = token.strip_prefix("channel:") {
filter.channel = match value {
"public" | "public_chat" => {
Some(crate::control_plane::ConversationChannelView::PublicChat)
}
"im" | "direct_im" => {
Some(crate::control_plane::ConversationChannelView::DirectIm)
}
_ => None,
};
} else if let Some(value) = token.strip_prefix("status:") {
filter.status = match value {
"active" => Some(ConversationStatusView::Active),
"expired" => Some(ConversationStatusView::Expired),
_ => None,
};
} else if let Some(value) = token.strip_prefix("from:") {
filter.active_from_unix_millis = value.parse().ok();
} else if let Some(value) = token.strip_prefix("to:") {
filter.active_to_unix_millis = value.parse().ok();
}
}
ControlRequest::ListConversations {
page: PageRequest {
cursor: self.conversation_cursors.last().copied(),
limit: 50,
},
filter,
}
}
fn visible_rows(&self) -> usize {
match self.screen {
OperatorScreen::Sessions => self.snapshot.sessions.len(),
OperatorScreen::Sessions => self.snapshot.conversations.len(),
OperatorScreen::Memories => self.snapshot.memories.len(),
OperatorScreen::Roaming => self.snapshot.schedules.len(),
OperatorScreen::Approvals => self.snapshot.approvals.len(),
@@ -801,6 +862,15 @@ impl OperatorTui {
record_id: record.record_id.clone(),
});
}
if self.screen == OperatorScreen::Sessions && key == 'd' {
return self
.snapshot
.conversations
.get(self.selected_row())
.map(|session| OperatorCommand::DeleteConversation {
session_id: session.session_id.clone(),
});
}
match key {
'p' => Some(OperatorCommand::Pause),
'u' => Some(OperatorCommand::Resume),
@@ -822,11 +892,12 @@ impl OperatorTui {
}),
'e' => self
.snapshot
.sessions
.conversations
.get(self.selected[Self::screen_index(OperatorScreen::Sessions)])
.map(|session| OperatorCommand::ExpireSession {
avatar_id: session.avatar_id.clone(),
direct_im: session.channel == "direct_im",
direct_im: session.channel
== crate::control_plane::ConversationChannelView::DirectIm,
}),
't' => self
.snapshot
@@ -853,6 +924,9 @@ impl OperatorTui {
self.scroll = 0;
if self.screen == OperatorScreen::Memories {
TuiAction::Request(ControlRequest::ListMemoryAgents { page: page() })
} else if self.screen == OperatorScreen::Sessions {
self.conversation_cursors.clear();
TuiAction::Request(self.conversation_request())
} else {
TuiAction::None
}
@@ -867,17 +941,32 @@ impl OperatorTui {
self.scroll = 0;
if self.screen == OperatorScreen::Memories {
TuiAction::Request(ControlRequest::ListMemoryAgents { page: page() })
} else if self.screen == OperatorScreen::Sessions {
self.conversation_cursors.clear();
TuiAction::Request(self.conversation_request())
} else {
TuiAction::None
}
}
TuiInput::ScrollUp => {
if self.screen == OperatorScreen::Sessions
&& self.snapshot.conversation_detail.is_some()
{
self.scroll = self.scroll.saturating_sub(1);
return TuiAction::None;
}
let index = Self::screen_index(self.screen);
self.selected[index] = self.selected[index].saturating_sub(1);
self.scroll = self.selected[index];
TuiAction::None
}
TuiInput::ScrollDown => {
if self.screen == OperatorScreen::Sessions
&& self.snapshot.conversation_detail.is_some()
{
self.scroll = self.scroll.saturating_add(1);
return TuiAction::None;
}
let index = Self::screen_index(self.screen);
self.selected[index] = self.selected[index]
.saturating_add(1)
@@ -945,6 +1034,8 @@ impl OperatorTui {
self.filter_editing = true;
self.status = if self.screen == OperatorScreen::Memories {
"memory search/filter: type query, Enter applies, Esc clears".into()
} else if self.screen == OperatorScreen::Sessions {
"conversation filter: avatar:/channel:/status:/from:/to:".into()
} else {
"event/audit filter: type query, Enter applies, Esc clears".into()
};
@@ -993,6 +1084,69 @@ impl OperatorTui {
TuiAction::None
}
}
TuiInput::ConversationLoad => self
.snapshot
.conversations
.get(self.selected_row())
.map_or(TuiAction::None, |session| {
TuiAction::Request(ControlRequest::GetConversation {
session_id: session.session_id.clone(),
page: PageRequest {
cursor: None,
limit: 50,
},
})
}),
TuiInput::ConversationClose => {
self.snapshot.conversation_detail = None;
self.conversation_detail_cursor = 0;
self.conversation_detail_next = None;
self.scroll = 0;
TuiAction::None
}
TuiInput::ConversationNextPage => {
if let Some(detail) = &self.snapshot.conversation_detail {
if let Some(cursor) = self.conversation_detail_next {
self.conversation_detail_cursor = cursor;
return TuiAction::Request(ControlRequest::GetConversation {
session_id: detail.conversation.session_id.clone(),
page: PageRequest {
cursor: Some(cursor),
limit: 50,
},
});
}
return TuiAction::None;
}
if let Some(cursor) = self.conversation_next_cursor {
self.conversation_cursors.push(cursor);
TuiAction::Request(self.conversation_request())
} else {
TuiAction::None
}
}
TuiInput::ConversationPreviousPage => {
if let Some(detail) = &self.snapshot.conversation_detail {
if self.conversation_detail_cursor > 0 {
self.conversation_detail_cursor =
self.conversation_detail_cursor.saturating_sub(50);
return TuiAction::Request(ControlRequest::GetConversation {
session_id: detail.conversation.session_id.clone(),
page: PageRequest {
cursor: (self.conversation_detail_cursor > 0)
.then_some(self.conversation_detail_cursor),
limit: 50,
},
});
}
return TuiAction::None;
}
if self.conversation_cursors.pop().is_some() {
TuiAction::Request(self.conversation_request())
} else {
TuiAction::None
}
}
TuiInput::MemoryLoad => {
if let (Some(agent), Some(record)) = (
self.snapshot.memory_agents.get(self.memory_agent),
@@ -1135,7 +1289,7 @@ impl OperatorTui {
ControlRequest::Runtime,
ControlRequest::Metrics,
ControlRequest::MemoryHealth,
ControlRequest::ListSessions { page: page() },
self.conversation_request(),
ControlRequest::ListScheduledJobs { page: page() },
ControlRequest::ListPendingApprovals { page: page() },
ControlRequest::ListAuditEvents { page: page() },
@@ -1269,6 +1423,26 @@ impl OperatorTui {
.get(self.selected[index])
.map(|item| item.session_id.clone());
self.snapshot.sessions = items;
self.snapshot.conversations = self
.snapshot
.sessions
.iter()
.map(|item| ConversationMetadataView {
session_id: item.session_id.clone(),
avatar_id: item.avatar_id.clone(),
channel: if item.channel == "direct_im" {
crate::control_plane::ConversationChannelView::DirectIm
} else {
crate::control_plane::ConversationChannelView::PublicChat
},
status: ConversationStatusView::Active,
created_unix_millis: item.created_unix_millis,
last_active_unix_millis: item.last_active_unix_millis,
turns: item.turns,
bytes: item.bytes,
compacted: false,
})
.collect();
self.selected[index] = identity
.and_then(|id| {
self.snapshot
@@ -1279,6 +1453,39 @@ impl OperatorTui {
.unwrap_or(0)
.min(self.snapshot.sessions.len().saturating_sub(1));
}
ControlPayload::Conversations(Page { items, next_cursor }) => {
let index = Self::screen_index(OperatorScreen::Sessions);
let identity = self
.snapshot
.conversations
.get(self.selected[index])
.map(|item| item.session_id.clone());
self.snapshot.conversations = items;
self.conversation_next_cursor = next_cursor;
self.snapshot.conversation_detail = None;
self.selected[index] = identity
.and_then(|id| {
self.snapshot
.conversations
.iter()
.position(|item| item.session_id == id)
})
.unwrap_or(0)
.min(self.snapshot.conversations.len().saturating_sub(1));
}
ControlPayload::ConversationDetail(detail) => {
self.conversation_detail_next = detail.entries.next_cursor.or_else(|| {
let total = detail
.active_transcript_entries
.saturating_add(detail.archived_transcript_entries);
let end = usize::try_from(self.conversation_detail_cursor)
.unwrap_or(usize::MAX)
.saturating_add(detail.transcript_entries.len());
(end < total).then(|| u64::try_from(end).unwrap_or(u64::MAX))
});
self.snapshot.conversation_detail = Some(detail);
self.scroll = 0;
}
ControlPayload::ScheduledJobs(Page { items, .. }) => {
let index = Self::screen_index(OperatorScreen::Roaming);
let identity = self
@@ -1701,7 +1908,7 @@ impl OperatorTui {
fn draw_sessions(&self, frame: &mut Frame<'_>, area: Rect, color: bool) {
let rows = self
.snapshot
.sessions
.conversations
.iter()
.enumerate()
.skip(self.selected_row().saturating_sub(5))
@@ -1712,24 +1919,32 @@ impl OperatorTui {
vec![
item.session_id.clone(),
item.avatar_id.clone(),
item.channel.clone(),
format!("{:?}", item.channel).to_lowercase(),
item.turns.to_string(),
item.bytes.to_string(),
format!("{} {:?}", item.bytes, item.status).to_lowercase(),
],
)
});
let detail = self.snapshot.sessions.get(self.selected_row()).map_or_else(
|| vec![Line::raw("No active sessions")],
let detail = self.snapshot.conversations.get(self.selected_row()).map_or_else(
|| vec![Line::raw("No conversations")],
|item| {
vec![
let mut lines = vec![
Line::raw(format!("session: {}", item.session_id)),
Line::raw(format!("avatar: {}", item.avatar_id)),
Line::raw(format!(
"created: {} last active: {}",
item.created_unix_millis, item.last_active_unix_millis
"status: {:?} compacted={} created: {} last: {}",
item.status, item.compacted, item.created_unix_millis, item.last_active_unix_millis
)),
Line::raw("[e] expire conversation (confirmation required)"),
]
Line::raw("Enter detail Esc list n/b page Up/Down scroll / filter [e] expire [d] delete"),
];
if let Some(detail) = &self.snapshot.conversation_detail
&& detail.conversation.session_id == item.session_id
{
lines.push(Line::raw(format!("Meta entries={} Mentra active={} archived={} running={}", detail.entries.items.len(), detail.active_transcript_entries, detail.archived_transcript_entries, detail.transcript_busy)));
lines.extend(detail.entries.items.iter().map(|entry| Line::raw(format!("{} {} {}: {}", entry.sequence, entry.role, entry.record_kind, entry.text))));
lines.extend(detail.transcript_entries.iter().map(|entry| Line::raw(format!("Mentra {} {} active={}", entry.entry_id, entry.kind, entry.active))));
}
lines.into_iter().skip(self.scroll).collect()
},
);
Self::draw_table_detail(
@@ -1737,7 +1952,7 @@ impl OperatorTui {
area,
color,
"Sessions",
["Session", "Avatar", "Channel", "Turns", "Bytes"],
["Session", "Avatar", "Channel", "Turns", "Bytes/status"],
rows,
detail,
);
@@ -2935,6 +3150,7 @@ async fn worker_send(
.map_err(|error| TuiError(format!("{:?}: {}", error.code, error.message)))
}
#[allow(clippy::too_many_lines)] // One exhaustive key map keeps screen precedence visible.
fn map_event(app: &OperatorTui, value: &event::Event) -> Option<TuiInput> {
let event::Event::Key(key) = value else {
return matches!(value, event::Event::Resize(_, _)).then_some(TuiInput::Refresh);
@@ -2980,6 +3196,12 @@ fn map_event(app: &OperatorTui, value: &event::Event) -> Option<TuiInput> {
};
}
match key.code {
event::KeyCode::Esc
if app.screen == OperatorScreen::Sessions
&& app.snapshot.conversation_detail.is_some() =>
{
Some(TuiInput::ConversationClose)
}
event::KeyCode::Tab | event::KeyCode::Right => Some(TuiInput::NextScreen),
event::KeyCode::BackTab | event::KeyCode::Left => Some(TuiInput::PreviousScreen),
event::KeyCode::Up => Some(TuiInput::ScrollUp),
@@ -2987,7 +3209,7 @@ fn map_event(app: &OperatorTui, value: &event::Event) -> Option<TuiInput> {
event::KeyCode::Char('/')
if matches!(
app.screen,
OperatorScreen::Timeline | OperatorScreen::Memories
OperatorScreen::Timeline | OperatorScreen::Memories | OperatorScreen::Sessions
) =>
{
Some(TuiInput::BeginFilter)
@@ -2995,15 +3217,24 @@ fn map_event(app: &OperatorTui, value: &event::Event) -> Option<TuiInput> {
event::KeyCode::Enter if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryLoad)
}
event::KeyCode::Enter if app.screen == OperatorScreen::Sessions => {
Some(TuiInput::ConversationLoad)
}
event::KeyCode::Char('g') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryNextAgent)
}
event::KeyCode::Char('n') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryNextPage)
}
event::KeyCode::Char('n') if app.screen == OperatorScreen::Sessions => {
Some(TuiInput::ConversationNextPage)
}
event::KeyCode::Char('b') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryPreviousPage)
}
event::KeyCode::Char('b') if app.screen == OperatorScreen::Sessions => {
Some(TuiInput::ConversationPreviousPage)
}
event::KeyCode::Char('k') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryCycleKind)
}
@@ -3088,6 +3319,43 @@ mod ratatui_private_tests {
app.command_shortcut('e'),
Some(OperatorCommand::ExpireSession { avatar_id, .. }) if avatar_id == "avatar-b"
));
assert!(matches!(
app.command_shortcut('d'),
Some(OperatorCommand::DeleteConversation { session_id }) if session_id == "b"
));
assert!(matches!(
app.reduce(TuiInput::ConversationLoad),
TuiAction::Request(ControlRequest::GetConversation { session_id, .. }) if session_id == "b"
));
app.snapshot.conversation_detail = Some(ConversationDetailView {
conversation: app.snapshot.conversations[app.selected_row()].clone(),
entries: Page {
items: Vec::new(),
next_cursor: Some(50),
},
mentra_agent_id: None,
profile_generation: None,
active_transcript_entries: 0,
archived_transcript_entries: 0,
transcript_entries: Vec::new(),
transcript_busy: false,
});
app.conversation_detail_next = Some(50);
assert!(matches!(
app.reduce(TuiInput::ConversationNextPage),
TuiAction::Request(ControlRequest::GetConversation {
page: PageRequest {
cursor: Some(50),
..
},
..
})
));
assert_eq!(app.reduce(TuiInput::ConversationClose), TuiAction::None);
assert_eq!(
app.snapshot.conversations[app.selected_row()].session_id,
"b"
);
app.apply(ControlPayload::Sessions(Page {
items: (0..20).map(|index| session(&index.to_string())).collect(),