use std::sync::Arc;
use aion_core::{
AssistantSessionEvent, AssistantSessionFrame, AssistantSessionId, AssistantSessionProjection,
AssistantSessionState, AssistantSessionSummary, AssistantTurnContext, TITLE_CHARACTERS,
};
use aion_integration_acp::catalogue::CatalogueHarness;
use aion_store::assistant::{
AssistantSessionRecord, AssistantSessionStore, AssistantTranscriptEvent,
};
use chrono::Utc;
use dashmap::DashMap;
use tokio::sync::broadcast;
use crate::config::ResolvedAssistantConfig;
use super::error::AssistantSessionError;
use super::launch::AssistantEndpoints;
use super::live::{LiveSession, Recorder};
#[derive(Clone)]
pub struct AssistantSessions {
inner: Arc<Inner>,
}
struct Inner {
live: DashMap<AssistantSessionId, Arc<LiveSession>>,
spawn_locks: DashMap<AssistantSessionId, Arc<tokio::sync::Mutex<()>>>,
channels: DashMap<AssistantSessionId, broadcast::Sender<AssistantSessionFrame>>,
document_edits: DashMap<AssistantSessionId, Arc<tokio::sync::Mutex<()>>>,
store: Arc<dyn AssistantSessionStore>,
config: ResolvedAssistantConfig,
endpoint: Option<AssistantEndpoints>,
catalogue: &'static [CatalogueHarness],
store_fault: std::sync::RwLock<Option<String>>,
}
impl AssistantSessions {
#[must_use]
pub fn new(
store: Arc<dyn AssistantSessionStore>,
config: ResolvedAssistantConfig,
endpoint: Option<AssistantEndpoints>,
catalogue: &'static [CatalogueHarness],
) -> Self {
Self {
inner: Arc::new(Inner {
live: DashMap::new(),
document_edits: DashMap::new(),
spawn_locks: DashMap::new(),
channels: DashMap::new(),
store,
config,
endpoint,
catalogue,
store_fault: std::sync::RwLock::new(None),
}),
}
}
pub(crate) fn catalogue(&self) -> &'static [CatalogueHarness] {
self.inner.catalogue
}
#[must_use]
pub fn config(&self) -> &ResolvedAssistantConfig {
&self.inner.config
}
#[must_use]
pub fn availability(&self) -> Availability {
match self.store_fault() {
Some(reason) => Availability::Unavailable { reason },
None => Availability::Available,
}
}
fn store_fault(&self) -> Option<String> {
self.inner
.store_fault
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn report_store_fault(&self, error: &AssistantSessionError) {
let reason = format!("{STORE_UNUSABLE}: {error}");
tracing::error!(%reason, "assistant sessions are unavailable on this server");
*self
.inner
.store_fault
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(reason);
}
pub(crate) fn clear_store_fault(&self) {
self.inner
.store_fault
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
}
pub async fn last_harness_pick(
&self,
subject: &str,
) -> Result<Option<String>, AssistantSessionError> {
Ok(self.inner.store.assistant_default_harness(subject).await?)
}
#[must_use]
pub fn hands_over_assistant_tools(&self) -> bool {
self.inner.endpoint.is_some()
}
#[must_use]
pub fn hands_over_general_mcp(&self) -> bool {
self.inner
.endpoint
.as_ref()
.and_then(AssistantEndpoints::aion_mcp_url)
.is_some()
}
pub(crate) fn aion_endpoint(&self) -> Option<&AssistantEndpoints> {
self.inner.endpoint.as_ref()
}
pub(crate) fn spawn_lock(&self, session_id: AssistantSessionId) -> Arc<tokio::sync::Mutex<()>> {
Arc::clone(
self.inner
.spawn_locks
.entry(session_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.value(),
)
}
pub(crate) fn live_ids(&self) -> Vec<AssistantSessionId> {
self.inner.live.iter().map(|entry| *entry.key()).collect()
}
pub(crate) fn store(&self) -> &Arc<dyn AssistantSessionStore> {
&self.inner.store
}
pub(super) fn document_edit_locks(
&self,
) -> &DashMap<AssistantSessionId, Arc<tokio::sync::Mutex<()>>> {
&self.inner.document_edits
}
pub(crate) fn live(&self, session_id: AssistantSessionId) -> Option<Arc<LiveSession>> {
self.inner
.live
.get(&session_id)
.map(|entry| Arc::clone(entry.value()))
}
pub(crate) fn adopt(&self, session: Arc<LiveSession>) {
self.inner.live.insert(session.session_id(), session);
}
pub(crate) fn forget(&self, session_id: AssistantSessionId) -> Option<Arc<LiveSession>> {
self.inner
.live
.remove(&session_id)
.map(|(_id, session)| session)
}
pub(crate) fn recorder(&self, session_id: AssistantSessionId) -> Recorder {
Recorder::new(
session_id,
Arc::clone(&self.inner.store),
self.channel(session_id),
)
}
fn channel(&self, session_id: AssistantSessionId) -> broadcast::Sender<AssistantSessionFrame> {
self.inner
.channels
.entry(session_id)
.or_insert_with(|| {
let (sender, _receiver) = broadcast::channel(LIVE_FRAME_BUFFER);
sender
})
.value()
.clone()
}
pub(crate) async fn owned_record(
&self,
subject: &str,
session_id: AssistantSessionId,
) -> Result<AssistantSessionRecord, AssistantSessionError> {
let record = self
.inner
.store
.get_assistant_session(&session_id)
.await?
.ok_or(AssistantSessionError::NotFound { session_id })?;
if record.subject != subject {
return Err(AssistantSessionError::NotYours {
session_id,
subject: subject.to_owned(),
});
}
Ok(record)
}
pub async fn record(
&self,
session_id: AssistantSessionId,
) -> Result<Option<AssistantSessionRecord>, AssistantSessionError> {
Ok(self.inner.store.get_assistant_session(&session_id).await?)
}
pub async fn state_of_session(
&self,
session_id: AssistantSessionId,
) -> Result<(AssistantSessionState, Option<String>), AssistantSessionError> {
self.state_of(session_id).await
}
pub async fn list(
&self,
subject: &str,
) -> Result<Vec<AssistantSessionSummary>, AssistantSessionError> {
let listing = self.inner.store.list_assistant_sessions().await?;
for row in &listing.undecodable {
tracing::warn!(
session = %row.session_id,
error = %row.error,
"an assistant session record could not be decoded and is omitted from the listing"
);
}
let mut summaries = Vec::new();
for record in listing.sessions {
if record.subject != subject {
continue;
}
let session_id = record.session_id;
let (state, reason) = self.state_of(session_id).await?;
summaries.push(record.summary(state, reason));
}
summaries.sort_by_key(|summary| std::cmp::Reverse(summary.created_at));
Ok(summaries)
}
pub(crate) async fn state_of(
&self,
session_id: AssistantSessionId,
) -> Result<(AssistantSessionState, Option<String>), AssistantSessionError> {
if self.is_live(session_id).await {
return Ok((AssistantSessionState::Live, None));
}
let head = self
.inner
.store
.assistant_transcript_head(&session_id)
.await?;
let tail_from = head.saturating_sub(SETTLEMENT_TAIL);
let tail = self
.transcript_from(session_id, tail_from.checked_sub(1))
.await?;
let projection = AssistantSessionProjection::of(tail.iter().map(|frame| &frame.event));
if projection.settled.is_some() {
return Ok(projection.state(None));
}
let whole = self.transcript_from(session_id, None).await?;
Ok(AssistantSessionProjection::of(whole.iter().map(|frame| &frame.event)).state(None))
}
pub(crate) async fn is_live(&self, session_id: AssistantSessionId) -> bool {
match self.live(session_id) {
Some(live) => live.is_alive().await,
None => false,
}
}
pub(crate) async fn transcript_from(
&self,
session_id: AssistantSessionId,
after: Option<u64>,
) -> Result<Vec<AssistantSessionFrame>, AssistantSessionError> {
let stored = self
.inner
.store
.assistant_transcript(&session_id, after)
.await?;
stored.iter().map(decode_frame).collect()
}
pub(crate) async fn projection(
&self,
session_id: AssistantSessionId,
) -> Result<AssistantSessionProjection, AssistantSessionError> {
let frames = self.transcript_from(session_id, None).await?;
Ok(AssistantSessionProjection::of(
frames.iter().map(|frame| &frame.event),
))
}
pub async fn latest_context(
&self,
session_id: AssistantSessionId,
) -> Result<Option<AssistantTurnContext>, AssistantSessionError> {
Ok(self.projection(session_id).await?.latest_context)
}
pub(crate) async fn settle(
&self,
session_id: AssistantSessionId,
state: AssistantSessionState,
reason: impl Into<String>,
) -> Result<(), AssistantSessionError> {
let reason = reason.into();
if state != AssistantSessionState::Ended
&& let Some((AssistantSessionState::Ended, cause)) =
self.projection(session_id).await?.settled
{
tracing::warn!(
%session_id,
requested = ?state,
%reason,
"a settling record that would leave `ended` was refused: ended is terminal"
);
return Err(AssistantSessionError::Ended {
session_id,
reason: cause.unwrap_or_else(|| "ended".to_owned()),
});
}
self.recorder(session_id)
.record(AssistantSessionEvent::State {
state,
reason: Some(reason),
})
.await
.map(drop)
}
pub(crate) async fn touch(
&self,
session_id: AssistantSessionId,
first_turn_text: Option<&str>,
) -> Result<(), AssistantSessionError> {
let Some(mut record) = self.inner.store.get_assistant_session(&session_id).await? else {
return Err(AssistantSessionError::NotFound { session_id });
};
record.updated_at = Utc::now();
record.turns = record.turns.saturating_add(1);
if record.title.is_none()
&& let Some(text) = first_turn_text
{
record.title = Some(text.trim().chars().take(TITLE_CHARACTERS).collect());
}
self.inner.store.put_assistant_session(record).await?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Availability {
Available,
Unavailable {
reason: String,
},
}
impl Availability {
#[must_use]
pub const fn is_available(&self) -> bool {
matches!(self, Self::Available)
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
match self {
Self::Available => None,
Self::Unavailable { reason } => Some(reason),
}
}
}
const SETTLEMENT_TAIL: u64 = 8;
const LIVE_FRAME_BUFFER: usize = 1_024;
pub const STORE_UNUSABLE: &str = "the durable store this server keeps assistant sessions in could not be read at start-up, so \
a conversation could not be recorded";
fn decode_frame(
stored: &AssistantTranscriptEvent,
) -> Result<AssistantSessionFrame, AssistantSessionError> {
let event: AssistantSessionEvent =
serde_json::from_slice(stored.payload.bytes()).map_err(|error| {
AssistantSessionError::Internal(format!(
"the assistant transcript event at index {} does not decode as a session frame \
({error}); the transcript is reported as unreadable rather than served with a \
hole in it",
stored.index
))
})?;
Ok(AssistantSessionFrame {
index: stored.index,
event,
})
}