use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::StateError;
use crate::tenant::TenantContext;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
pub const STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConversationState {
pub schema_version: u32,
pub session_id: String,
pub tenant_id: String,
pub env_id: String,
pub messages: Vec<ChatMessage>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl ConversationState {
pub fn empty(tenant: &TenantContext, session_id: &str) -> Self {
let now = Utc::now();
Self {
schema_version: STATE_SCHEMA_VERSION,
session_id: session_id.to_string(),
tenant_id: tenant.tenant_id.clone(),
env_id: tenant.env_id.clone(),
messages: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn truncate_history(&mut self, max_turns: u32) {
let max = max_turns as usize;
while self
.messages
.iter()
.filter(|m| !matches!(m, ChatMessage::System { .. }))
.count()
> max
{
if let Some(position) = self
.messages
.iter()
.position(|m| !matches!(m, ChatMessage::System { .. }))
{
self.messages.remove(position);
} else {
break;
}
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
System {
content: String,
},
User {
content: String,
},
Assistant {
content: String,
tool_calls: Vec<ToolCallRecord>,
},
Tool {
call_id: String,
content: serde_json::Value,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ToolCallRecord {
pub call_id: String,
pub extension_id: String,
pub tool_name: String,
pub args: serde_json::Value,
}
pub trait AgentStateStore: Send + Sync {
fn load<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<ConversationState, StateError>> + Send + 'a>>;
fn save<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
state: &'a ConversationState,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>>;
fn acquire_lock<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
wait: Duration,
) -> Pin<Box<dyn Future<Output = Result<SessionLock, StateError>> + Send + 'a>>;
}
pub struct SessionLock {
pub(crate) inner: Box<dyn SessionLockInner>,
}
impl SessionLock {
#[allow(dead_code)] pub(crate) fn new(inner: Box<dyn SessionLockInner>) -> Self {
Self { inner }
}
pub async fn refresh(&self) -> Result<(), StateError> {
self.inner.refresh().await
}
}
impl Drop for SessionLock {
fn drop(&mut self) {
self.inner.release();
}
}
pub trait SessionLockInner: Send + Sync {
fn refresh<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>>;
fn release(&self);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_state_has_schema_version_1() {
let tenant_context = TenantContext::new("a", "b");
let conversation_state = ConversationState::empty(&tenant_context, "sess");
assert_eq!(conversation_state.schema_version, STATE_SCHEMA_VERSION);
assert_eq!(conversation_state.schema_version, 1);
assert_eq!(conversation_state.session_id, "sess");
assert_eq!(conversation_state.tenant_id, "a");
assert_eq!(conversation_state.env_id, "b");
assert!(conversation_state.messages.is_empty());
}
#[test]
#[allow(clippy::panic)] fn truncate_history_drops_oldest_non_system_first() {
let tenant_context = TenantContext::new("a", "b");
let mut conversation_state = ConversationState::empty(&tenant_context, "x");
conversation_state.messages.push(ChatMessage::System {
content: "sys".into(),
});
conversation_state.messages.push(ChatMessage::User {
content: "u1".into(),
});
conversation_state.messages.push(ChatMessage::Assistant {
content: "a1".into(),
tool_calls: vec![],
});
conversation_state.messages.push(ChatMessage::User {
content: "u2".into(),
});
conversation_state.messages.push(ChatMessage::Assistant {
content: "a2".into(),
tool_calls: vec![],
});
conversation_state.truncate_history(2);
assert_eq!(conversation_state.messages.len(), 3);
assert!(matches!(
conversation_state.messages[0],
ChatMessage::System { .. }
));
if let ChatMessage::User { content } = &conversation_state.messages[1] {
assert_eq!(content, "u2");
} else {
panic!("expected User u2 at position 1");
}
}
}