use std::sync::Arc;
use parking_lot::Mutex;
use serde_json::Value;
use crate::client::LLMClient;
use crate::micro::salvage_json;
const SCORE_SYSTEM: &str = "You rate one conversation turn for long-term value. \
Reply with ONLY a JSON object {\"score\":N} where N is 1-5: \
5 = critical fact or decision worth keeping verbatim, \
4 = useful detail, \
3 = mild context value, \
2 = mostly filler, \
1 = worthless. \
Score low when unsure.";
const AUDIT_SYSTEM: &str = "You re-rate conversation turns for long-term value. \
Each turn is prefixed with its sequence number [seq]. \
Reply with ONLY a JSON array [{\"seq\":N,\"score\":N}] covering EVERY listed seq, \
scores 1-5: \
5 = critical fact or decision worth keeping verbatim, \
4 = useful detail, \
3 = mild context value, \
2 = mostly filler, \
1 = worthless. \
Score low when unsure.";
const MEMORY_SYSTEM: &str = "You compress raw conversation notes into a dense rolling summary. \
Keep only durable facts, decisions and preferences; drop filler. \
Preserve concrete names, numbers and dates. \
Output ONLY the summary text, nothing else.";
const S_TIER_SCORE: u8 = 5;
const MID_TIER_MIN: u8 = 3;
const MID_TIER_MAX: u8 = 4;
const LOW_EVICT_SCORE: u8 = 2;
#[derive(Debug, Clone)]
pub struct CompactConfig {
pub trigger_turns: usize,
pub history_turns: usize,
pub grace_turns: usize,
pub memory_max_chars: usize,
pub critical_max_items: usize,
pub critical_reaudit: usize,
}
impl Default for CompactConfig {
fn default() -> Self {
Self {
trigger_turns: 6,
history_turns: 6,
grace_turns: 3,
memory_max_chars: 500,
critical_max_items: 8,
critical_reaudit: 6,
}
}
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnEntry {
pub seq: u64,
pub user: String,
pub assistant: String,
pub score: Option<u8>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct CompactionState {
entries: Vec<TurnEntry>,
critical: Vec<String>,
memory: String,
last_audit_seq: u64,
}
impl CompactionState {
pub fn from_parts(
entries: Vec<TurnEntry>,
critical: Vec<String>,
memory: String,
last_audit_seq: u64,
) -> Self {
Self {
entries,
critical,
memory,
last_audit_seq,
}
}
pub fn entries(&self) -> &[TurnEntry] {
&self.entries
}
pub fn critical(&self) -> &[String] {
&self.critical
}
pub fn memory(&self) -> &str {
&self.memory
}
pub fn last_audit_seq(&self) -> u64 {
self.last_audit_seq
}
fn next_seq(&self) -> u64 {
self.entries.last().map(|e| e.seq + 1).unwrap_or(1)
}
fn turns_since_audit(&self) -> usize {
self.entries
.iter()
.filter(|e| e.seq > self.last_audit_seq)
.count()
}
fn apply_score(&mut self, seq: u64, score: u8) {
if let Some(entry) = self.entries.iter_mut().find(|e| e.seq == seq) {
entry.score = Some(score);
}
}
fn apply_scores(&mut self, updates: &[(u64, u8)]) {
for (seq, score) in updates {
self.apply_score(*seq, *score);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionSnapshot {
pub turn_count: usize,
pub scored_count: usize,
pub critical_count: usize,
pub memory_chars: usize,
pub last_audit_seq: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompactEvent {
Scored {
seq: u64,
score: u8,
},
Audited {
critical_kept: usize,
memory_chars: usize,
dropped_seqs: Vec<u64>,
},
Skipped {
reason: &'static str,
},
}
pub struct Compactor {
config: CompactConfig,
client: Arc<dyn LLMClient>,
state: Mutex<CompactionState>,
}
impl Compactor {
pub fn new(config: CompactConfig, client: Arc<dyn LLMClient>) -> Self {
Self {
config,
client,
state: Mutex::new(CompactionState::default()),
}
}
pub fn with_client(client: Arc<dyn LLMClient>) -> Self {
Self::new(CompactConfig::default(), client)
}
pub async fn record_turn(&self, user: String, assistant: String) -> CompactEvent {
let seq = {
let mut state = self.lock();
let seq = state.next_seq();
state.entries.push(TurnEntry {
seq,
user: user.clone(),
assistant: assistant.clone(),
score: None,
});
seq
};
let input = format!("user: {}\nassistant: {}", user, assistant);
let Ok(text) = self.client.generate_with_system(SCORE_SYSTEM, &input).await else {
return CompactEvent::Skipped {
reason: "score-call",
};
};
match parse_score(&text) {
Some(score) => {
self.lock().apply_score(seq, score);
CompactEvent::Scored { seq, score }
}
None => CompactEvent::Skipped {
reason: "score-parse",
},
}
}
pub async fn audit_if_due(&self) -> Vec<CompactEvent> {
let (due, candidates) = {
let state = self.lock();
let due = state.turns_since_audit() >= self.config.trigger_turns
|| state.entries.len() > self.config.history_turns;
let candidates: Vec<TurnEntry> = state
.entries
.iter()
.filter(|e| {
e.score.is_none() || e.score <= Some(self.config.critical_reaudit as u8)
})
.cloned()
.collect();
(due, candidates)
};
if !due {
return vec![CompactEvent::Skipped { reason: "not-due" }];
}
let listing = candidates
.iter()
.map(|e| format!("[{}] user: {}\nassistant: {}", e.seq, e.user, e.assistant))
.collect::<Vec<_>>()
.join("\n---\n");
let Ok(text) = self
.client
.generate_with_system(AUDIT_SYSTEM, &listing)
.await
else {
return vec![CompactEvent::Skipped {
reason: "audit-call",
}];
};
let updates = parse_audit_scores(&text);
if updates.is_empty() {
return vec![CompactEvent::Skipped {
reason: "audit-parse",
}];
}
let (dropped_seqs, mid_facts, previous_memory) = {
let mut state = self.lock();
state.apply_scores(&updates);
let keep_from = state.entries.len().saturating_sub(self.config.grace_turns);
let mut dropped_seqs = Vec::new();
let mut kept: Vec<TurnEntry> = Vec::with_capacity(state.entries.len());
for (position, entry) in state.entries.drain(..).enumerate() {
let low = matches!(entry.score, Some(score) if score <= LOW_EVICT_SCORE);
let protected = position >= keep_from;
if protected || !low {
kept.push(entry);
} else {
dropped_seqs.push(entry.seq);
}
}
state.entries = kept;
let s_tier_items: Vec<String> = state
.entries
.iter()
.filter(|entry| entry.score == Some(S_TIER_SCORE))
.map(critical_item_text)
.collect();
for item in s_tier_items {
if !state.critical.contains(&item) {
state.critical.push(item);
}
}
while state.critical.len() > self.config.critical_max_items {
state.critical.remove(0);
}
let window_end = state.entries.len().saturating_sub(self.config.grace_turns);
let mid_facts: Vec<String> = state
.entries
.iter()
.take(window_end)
.filter(|e| {
matches!(e.score, Some(score) if (MID_TIER_MIN..=MID_TIER_MAX).contains(&score))
})
.map(mid_fact_text)
.collect();
let previous_memory = state.memory.clone();
state.last_audit_seq = state
.entries
.last()
.map(|e| e.seq)
.unwrap_or(state.last_audit_seq);
(dropped_seqs, mid_facts, previous_memory)
};
if !mid_facts.is_empty() {
let input = format!(
"Previous summary:\n{}\n\nNew notes:\n{}",
previous_memory,
mid_facts.join("\n")
);
if let Ok(summary) = self
.client
.generate_with_system(MEMORY_SYSTEM, &input)
.await
{
let trimmed = summary.trim();
if !trimmed.is_empty() {
self.lock().memory = truncate_chars(trimmed, self.config.memory_max_chars);
}
}
}
let event = {
let state = self.lock();
CompactEvent::Audited {
critical_kept: state.critical.len(),
memory_chars: state.memory.chars().count(),
dropped_seqs,
}
};
vec![event]
}
pub fn build_context(&self, base: &str, recent_window: usize) -> Vec<(String, String)> {
let state = self.lock();
let mut messages = vec![("system".to_string(), base.to_string())];
if !state.critical.is_empty() {
messages.push((
"system".to_string(),
format!(
"Critical facts to preserve verbatim:\n{}",
state
.critical
.iter()
.map(|item| format!("- {}", item))
.collect::<Vec<_>>()
.join("\n")
),
));
}
if !state.memory.is_empty() {
messages.push((
"system".to_string(),
format!("Conversation memory summary:\n{}", state.memory),
));
}
let start = state.entries.len().saturating_sub(recent_window);
for entry in &state.entries[start..] {
messages.push(("user".to_string(), entry.user.clone()));
messages.push(("assistant".to_string(), entry.assistant.clone()));
}
messages
}
pub fn export(&self) -> CompactionState {
self.lock().clone()
}
pub fn hydrate(
config: CompactConfig,
client: Arc<dyn LLMClient>,
state: CompactionState,
) -> Self {
Self {
config,
client,
state: Mutex::new(state),
}
}
pub fn state_snapshot(&self) -> CompactionSnapshot {
let state = self.lock();
CompactionSnapshot {
turn_count: state.entries.len(),
scored_count: state.entries.iter().filter(|e| e.score.is_some()).count(),
critical_count: state.critical.len(),
memory_chars: state.memory.chars().count(),
last_audit_seq: state.last_audit_seq,
}
}
fn lock(&self) -> parking_lot::MutexGuard<'_, CompactionState> {
self.state.lock()
}
}
fn critical_item_text(entry: &TurnEntry) -> String {
format!("user: {}\nassistant: {}", entry.user, entry.assistant)
}
fn mid_fact_text(entry: &TurnEntry) -> String {
format!(
"[{}] user: {}; assistant: {}",
entry.seq, entry.user, entry.assistant
)
}
fn truncate_chars(text: &str, max: usize) -> String {
text.char_indices()
.nth(max)
.map_or_else(|| text.to_string(), |(idx, _)| text[..idx].to_string())
}
fn parse_score(text: &str) -> Option<u8> {
let value = salvage_json(text)?;
let raw = value.get("score").and_then(|field| {
field.as_i64().or_else(|| {
field
.as_str()
.and_then(|string| string.trim().parse::<i64>().ok())
})
})?;
Some(raw.clamp(1, 5) as u8)
}
fn parse_audit_scores(text: &str) -> Vec<(u64, u8)> {
let Some(value) = salvage_json(text) else {
return Vec::new();
};
let rows: Vec<Value> = match value {
Value::Array(rows) => rows,
Value::Object(_) => vec![value],
_ => return Vec::new(),
};
rows.iter()
.filter_map(|row| {
let seq = row.get("seq")?.as_u64()?;
let score = row.get("score")?.as_i64()?;
Some((seq, score.clamp(1, 5) as u8))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ares_types::types::{AppError, Result};
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
type Step = std::result::Result<String, AppError>;
struct ScriptedClient {
replies: Box<dyn Fn(usize) -> Step + Send + Sync>,
calls: AtomicUsize,
}
impl ScriptedClient {
fn new<F>(replies: F) -> Self
where
F: Fn(usize) -> Step + Send + Sync + 'static,
{
Self {
replies: Box::new(replies),
calls: AtomicUsize::new(0),
}
}
fn call_index(&self) -> usize {
self.calls.fetch_add(1, Ordering::SeqCst)
}
}
#[async_trait]
impl LLMClient for ScriptedClient {
async fn generate(&self, _prompt: &str) -> Result<String> {
Err(AppError::Internal("unused".into()))
}
async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
(self.replies)(self.call_index())
}
async fn generate_with_history(
&self,
_messages: &[(String, String)],
) -> Result<crate::client::LLMResponse> {
Err(AppError::Internal("unused".into()))
}
async fn generate_with_tools(
&self,
_prompt: &str,
_tools: &[ares_types::types::ToolDefinition],
) -> Result<crate::client::LLMResponse> {
Err(AppError::Internal("unused".into()))
}
async fn generate_with_tools_and_history(
&self,
_messages: &[crate::coordinator::ConversationMessage],
_tools: &[ares_types::types::ToolDefinition],
) -> Result<crate::client::LLMResponse> {
Err(AppError::Internal("unused".into()))
}
async fn stream(
&self,
_prompt: &str,
) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
Err(AppError::Internal("unused".into()))
}
async fn stream_with_system(
&self,
_system: &str,
_prompt: &str,
) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
Err(AppError::Internal("unused".into()))
}
async fn stream_with_history(
&self,
_messages: &[(String, String)],
) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
Err(AppError::Internal("unused".into()))
}
fn model_name(&self) -> &str {
"compact-scripted-mock"
}
}
fn config() -> CompactConfig {
CompactConfig {
trigger_turns: 1,
history_turns: 16,
grace_turns: 1,
memory_max_chars: 500,
critical_max_items: 8,
critical_reaudit: 6,
}
}
#[tokio::test]
async fn record_turn_scores_the_pair() {
let client = Arc::new(ScriptedClient::new(|_| Ok("{\"score\": 4}".into())));
let compactor = Compactor::with_client(client);
let event = compactor
.record_turn("what is ares?".to_string(), "an api gateway".to_string())
.await;
assert_eq!(event, CompactEvent::Scored { seq: 1, score: 4 });
let snapshot = compactor.state_snapshot();
assert_eq!(snapshot.turn_count, 1);
assert_eq!(snapshot.scored_count, 1);
assert_eq!(snapshot.last_audit_seq, 0, "scoring alone is not an audit");
}
#[tokio::test]
async fn record_turn_parse_failure_is_skipped_not_err() {
let client = Arc::new(ScriptedClient::new(|_| Ok("no json at all".into())));
let compactor = Compactor::with_client(client);
let event = compactor
.record_turn("u".to_string(), "a".to_string())
.await;
assert_eq!(
event,
CompactEvent::Skipped {
reason: "score-parse"
}
);
let snapshot = compactor.state_snapshot();
assert_eq!(snapshot.turn_count, 1, "turn is kept unscored");
assert_eq!(snapshot.scored_count, 0);
}
#[tokio::test]
async fn record_turn_transport_failure_is_skipped_not_err() {
let client = Arc::new(ScriptedClient::new(|_| {
Err(AppError::External("down".into()))
}));
let compactor = Compactor::with_client(client);
let event = compactor
.record_turn("u".to_string(), "a".to_string())
.await;
assert_eq!(
event,
CompactEvent::Skipped {
reason: "score-call"
}
);
assert_eq!(compactor.state_snapshot().turn_count, 1);
}
#[tokio::test]
async fn audit_hoists_s_tier_verbatim_and_evicts_low_outside_grace() {
let client = Arc::new(ScriptedClient::new(|call| {
match call {
0..=3 => Ok(format!("{{\"score\":{}}}", [5, 1, 4, 1][call])),
4 => Ok("[{\"seq\":1,\"score\":5},{\"seq\":2,\"score\":1},{\"seq\":3,\"score\":4},{\"seq\":4,\"score\":1}]".into()),
5 => Ok("likes rust; dislikes yaml".into()),
_ => Err(AppError::Internal("unexpected call".into())),
}
}));
let compactor = Compactor::new(config(), client);
for index in 0..4u64 {
let event = compactor
.record_turn(format!("u{}", index), format!("a{}", index))
.await;
assert!(
matches!(event, CompactEvent::Scored { .. }),
"seed turn {} should score",
index
);
}
let events = compactor.audit_if_due().await;
assert_eq!(events.len(), 1);
assert_eq!(
events[0],
CompactEvent::Audited {
critical_kept: 1,
memory_chars: "likes rust; dislikes yaml".chars().count(),
dropped_seqs: vec![2],
}
);
let snapshot = compactor.state_snapshot();
assert_eq!(snapshot.turn_count, 3, "seq 2 evicted; 1, 3, 4 stay");
assert_eq!(snapshot.critical_count, 1);
assert_eq!(snapshot.last_audit_seq, 4);
let client = Arc::new(ScriptedClient::new(|call| match call {
0 => Ok("{\"score\":1}".into()),
1 => Ok("[{\"seq\":5,\"score\":1}]".into()),
_ => Err(AppError::Internal("unexpected call".into())),
}));
let compactor = Compactor::new(config(), client);
compactor
.record_turn("fresh".to_string(), "low value".to_string())
.await;
let events = compactor.audit_if_due().await;
assert_eq!(
events[0],
CompactEvent::Audited {
critical_kept: 0,
memory_chars: 0,
dropped_seqs: vec![],
},
"newest grace_turns entry survives despite score 1"
);
}
#[tokio::test]
async fn audit_memory_failure_keeps_previous_memory() {
let client = Arc::new(ScriptedClient::new(|call| match call {
0..=1 => Ok("{\"score\":3}".into()),
2 => Ok("[{\"seq\":1,\"score\":3},{\"seq\":2,\"score\":3}]".into()),
3 => Ok("first summary".into()),
4 => Ok("{\"score\":3}".into()),
5 => Ok("[{\"seq\":3,\"score\":3}]".into()),
_ => Err(AppError::External("memory call down".into())),
}));
let compactor = Compactor::new(config(), client);
compactor.record_turn("u1".into(), "a1".into()).await;
compactor.record_turn("u2".into(), "a2".into()).await;
let first = compactor.audit_if_due().await;
assert_eq!(
first[0],
CompactEvent::Audited {
critical_kept: 0,
memory_chars: "first summary".chars().count(),
dropped_seqs: vec![],
}
);
compactor.record_turn("u3".into(), "a3".into()).await;
let second = compactor.audit_if_due().await;
assert!(matches!(second[0], CompactEvent::Audited { .. }));
let snapshot = compactor.state_snapshot();
assert_eq!(
snapshot.memory_chars,
"first summary".chars().count(),
"failed rebuild falls back to previous memory"
);
}
#[tokio::test]
async fn audit_call_failures_degrade_to_skipped() {
let failing = Arc::new(ScriptedClient::new(|call| match call {
0 => Ok("{\"score\":1}".into()),
_ => Err(AppError::External("audit down".into())),
}));
let compactor = Compactor::new(config(), failing);
compactor.record_turn("u".into(), "a".into()).await;
assert_eq!(
compactor.audit_if_due().await,
vec![CompactEvent::Skipped {
reason: "audit-call"
}]
);
let garbage = Arc::new(ScriptedClient::new(|call| match call {
0 => Ok("{\"score\":1}".into()),
1 => Ok("total gibberish".into()),
_ => Err(AppError::Internal("unexpected call".into())),
}));
let compactor = Compactor::new(config(), garbage);
compactor.record_turn("u".into(), "a".into()).await;
assert_eq!(
compactor.audit_if_due().await,
vec![CompactEvent::Skipped {
reason: "audit-parse"
}]
);
let snapshot = compactor.state_snapshot();
assert_eq!(snapshot.turn_count, 1, "skipped audits keep state intact");
assert_eq!(snapshot.last_audit_seq, 0);
}
#[tokio::test]
async fn audit_skips_when_not_due() {
let client = Arc::new(ScriptedClient::new(|_| {
Err(AppError::Internal("no calls expected".into()))
}));
let quiet = CompactConfig {
trigger_turns: 100,
history_turns: 100,
..config()
};
let compactor = Compactor::new(quiet, client);
assert_eq!(
compactor.audit_if_due().await,
vec![CompactEvent::Skipped { reason: "not-due" }]
);
}
#[tokio::test]
async fn build_context_orders_base_critical_memory_recent() {
let client = Arc::new(ScriptedClient::new(|call| match call {
0..=2 => Ok(format!("{{\"score\":{}}}", [4, 5, 2][call])),
3 => Ok(
"[{\"seq\":1,\"score\":4},{\"seq\":2,\"score\":5},{\"seq\":3,\"score\":2}]".into(),
),
4 => Ok("she prefers dark mode".into()),
_ => Err(AppError::Internal("unexpected call".into())),
}));
let compactor = Compactor::new(config(), client);
compactor
.record_turn("theme?".into(), "dark mode".into())
.await;
compactor.record_turn("stack?".into(), "rust".into()).await;
compactor.record_turn("tabs?".into(), "spaces".into()).await;
compactor.audit_if_due().await;
let messages = compactor.build_context("You are helpful.", 8);
assert_eq!(messages.len(), 9, "base + critical + memory + 3 turns x2");
assert_eq!(
messages[0],
("system".to_string(), "You are helpful.".to_string())
);
assert_eq!(messages[1].0, "system");
assert!(
messages[1].1.contains("Critical facts") && messages[1].1.contains("user: stack?"),
"critical slot comes right after base and holds the S-tier pair"
);
assert_eq!(messages[2].0, "system");
assert!(
messages[2].1.contains("Conversation memory summary")
&& messages[2].1.contains("she prefers dark mode"),
"memory slot follows critical"
);
assert_eq!(messages[3], ("user".to_string(), "theme?".to_string()));
assert_eq!(
messages[4],
("assistant".to_string(), "dark mode".to_string())
);
assert_eq!(messages[5], ("user".to_string(), "stack?".to_string()));
assert_eq!(messages[6], ("assistant".to_string(), "rust".to_string()));
assert_eq!(messages[7], ("user".to_string(), "tabs?".to_string()));
assert_eq!(messages[8], ("assistant".to_string(), "spaces".to_string()));
let trimmed = compactor.build_context("base", 1);
assert_eq!(trimmed.len(), 5, "base + critical + memory + 1 turn x2");
assert_eq!(trimmed[3], ("user".to_string(), "tabs?".to_string()));
assert_eq!(trimmed[4], ("assistant".to_string(), "spaces".to_string()));
let bare = Compactor::with_client(Arc::new(ScriptedClient::new(|_| {
Err(AppError::Internal("unused".into()))
})));
assert_eq!(
bare.build_context("only", 4),
vec![("system".to_string(), "only".to_string())]
);
}
#[test]
fn parse_score_clamps_and_tolerates_strings() {
assert_eq!(parse_score("{\"score\":4}"), Some(4));
assert_eq!(parse_score("Sure! {\"score\":\"9\"}"), Some(5));
assert_eq!(parse_score("{\"score\":0}"), Some(1));
assert_eq!(parse_score("garbage"), None);
}
#[test]
fn truncate_chars_respects_boundaries() {
assert_eq!(truncate_chars("hello", 50), "hello");
assert_eq!(truncate_chars("héllo", 2), "hé");
}
#[tokio::test]
async fn state_serde_round_trip_preserves_entries_critical_memory_audit_seq() {
let client = Arc::new(ScriptedClient::new(|_| {
Err(AppError::Internal("unused".into()))
}));
let compactor = Compactor::with_client(client);
{
let mut state = compactor.lock();
state.entries.push(TurnEntry {
seq: 1,
user: "theme?".to_string(),
assistant: "dark mode".to_string(),
score: Some(5),
});
state.entries.push(TurnEntry {
seq: 2,
user: "stack?".to_string(),
assistant: "rust".to_string(),
score: None,
});
state
.critical
.push("user: theme?\nassistant: dark mode".to_string());
state.memory = "User prefers dark mode.".to_string();
state.last_audit_seq = 1;
}
let exported = compactor.export();
let json = serde_json::to_string(&exported).expect("serialize");
let restored: CompactionState = serde_json::from_str(&json).expect("deserialize");
let revived = Compactor::hydrate(
CompactConfig::default(),
Arc::new(ScriptedClient::new(|_| {
Err(AppError::Internal("unused".into()))
})),
restored,
);
assert_eq!(revived.export().entries, exported.entries);
assert_eq!(revived.export().critical, exported.critical);
assert_eq!(revived.export().memory, exported.memory);
assert_eq!(revived.export().last_audit_seq, 1);
assert_eq!(
revived.audit_if_due().await.first(),
Some(&CompactEvent::Skipped { reason: "not-due" })
);
}
}