use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::{Arc, Mutex, PoisonError};
use futures::future::BoxFuture;
use crate::agent::estimate_messages;
use crate::client::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmClient, TokenUsage};
use crate::error::AgentError;
use crate::ids::SessionId;
pub trait SessionStore: Send + Sync + std::fmt::Debug {
fn load<'a>(&'a self, id: &'a SessionId)
-> BoxFuture<'a, Result<Vec<ChatMessage>, AgentError>>;
fn save<'a>(
&'a self,
id: &'a SessionId,
messages: &'a [ChatMessage],
) -> BoxFuture<'a, Result<(), AgentError>>;
}
#[derive(Debug, Default)]
pub struct InMemorySessionStore {
sessions: Mutex<HashMap<SessionId, Vec<ChatMessage>>>,
}
impl InMemorySessionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn shared(self) -> Arc<dyn SessionStore> {
Arc::new(self)
}
}
impl SessionStore for InMemorySessionStore {
fn load<'a>(
&'a self,
id: &'a SessionId,
) -> BoxFuture<'a, Result<Vec<ChatMessage>, AgentError>> {
let messages = self
.sessions
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(id)
.cloned()
.unwrap_or_default();
Box::pin(std::future::ready(Ok(messages)))
}
fn save<'a>(
&'a self,
id: &'a SessionId,
messages: &'a [ChatMessage],
) -> BoxFuture<'a, Result<(), AgentError>> {
self.sessions
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(id.clone(), messages.to_vec());
Box::pin(std::future::ready(Ok(())))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Compaction {
pub trigger_tokens: u32,
pub keep_recent_tokens: u32,
pub summary_max_tokens: u32,
}
impl Default for Compaction {
fn default() -> Self {
Self {
trigger_tokens: 24_000,
keep_recent_tokens: 8_000,
summary_max_tokens: 1_024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionReport {
pub summarized_messages: usize,
pub tokens_before: u32,
pub tokens_after: u32,
pub usage: TokenUsage,
}
pub const SUMMARY_PREFIX: &str = "[Summary of the earlier conversation]";
const SUMMARY_INSTRUCTIONS: &str = "You compress conversation history for an AI agent that will continue the conversation. Write a dense summary of the transcript: the user's goals and standing instructions, decisions made, facts learned (with exact names, ids, numbers), tool results that still matter, and open tasks. Leave out small talk. Write plain prose or bullets; do not address the user.";
fn split_point(messages: &[ChatMessage], keep_recent_tokens: u32) -> Option<usize> {
let mut tail_tokens: u32 = 0;
let mut split = None;
for index in (1..messages.len()).rev() {
let cost = estimate_messages(messages.get(index..=index).unwrap_or_default());
tail_tokens = tail_tokens.saturating_add(cost);
if tail_tokens > keep_recent_tokens {
break;
}
if messages
.get(index)
.is_some_and(|message| message.role == ChatRole::User)
{
split = Some(index);
}
}
split
}
fn render_transcript(messages: &[ChatMessage]) -> String {
let mut out = String::new();
for message in messages {
let role = match message.role {
ChatRole::System => "system",
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::Tool => "tool",
};
for part in &message.content {
match part {
ContentPart::Text(text) => {
let _ = writeln!(out, "{role}: {text}");
}
ContentPart::ToolCall {
name, arguments, ..
} => {
let _ = writeln!(out, "{role} called {name} with {arguments}");
}
ContentPart::ToolResult { content, .. } => {
let clipped: String = content.chars().take(600).collect();
let _ = writeln!(out, "{role} result: {clipped}");
}
}
}
}
out
}
pub async fn compact(
client: &dyn LlmClient,
messages: &mut Vec<ChatMessage>,
settings: &Compaction,
) -> Result<Option<CompactionReport>, AgentError> {
let tokens_before = estimate_messages(messages);
if tokens_before <= settings.trigger_tokens {
return Ok(None);
}
let Some(split) = split_point(messages, settings.keep_recent_tokens) else {
return Ok(None);
};
let head = messages.get(..split).unwrap_or_default();
let request = ChatRequest {
messages: vec![
ChatMessage::text(ChatRole::System, SUMMARY_INSTRUCTIONS),
ChatMessage::text(ChatRole::User, render_transcript(head)),
],
tools: Vec::new(),
max_tokens: Some(settings.summary_max_tokens),
temperature: None,
};
let response = client.chat(&request).await?;
let summary = response
.content
.iter()
.filter_map(|part| match part {
ContentPart::Text(text) => Some(text.as_str()),
ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } => None,
})
.collect::<Vec<_>>()
.join("");
let mut compacted = Vec::with_capacity(messages.len().saturating_sub(split).saturating_add(1));
compacted.push(ChatMessage::text(
ChatRole::User,
format!("{SUMMARY_PREFIX}\n{}", summary.trim()),
));
compacted.extend(messages.drain(split..));
*messages = compacted;
Ok(Some(CompactionReport {
summarized_messages: split,
tokens_before,
tokens_after: estimate_messages(messages),
usage: response.usage,
}))
}
#[cfg(test)]
mod tests;