Skip to main content

roboticus_agent/
digest.rs

1use roboticus_core::config::DigestConfig;
2use roboticus_db::Database;
3use roboticus_db::sessions::{self, Session};
4use tracing::{debug, info};
5
6/// A generated summary of a session's key events and outcomes.
7#[derive(Debug, Clone)]
8pub struct EpisodicDigest {
9    pub session_id: String,
10    pub agent_id: String,
11    pub summary: String,
12    pub key_topics: Vec<String>,
13    pub turn_count: i64,
14    pub importance: i32,
15}
16
17impl EpisodicDigest {
18    /// Generate a digest from a session's message history.
19    pub fn from_session(db: &Database, session: &Session) -> Option<Self> {
20        let messages = sessions::list_messages(db, &session.id, None)
21            .inspect_err(|e| tracing::warn!(error = %e, session_id = %session.id, "failed to list messages for digest"))
22            .ok()?;
23        if messages.is_empty() {
24            return None;
25        }
26
27        let mut topics = Vec::new();
28        let mut summary_parts = Vec::new();
29        let turn_count = messages.len() as i64;
30
31        for msg in &messages {
32            if msg.role == "user" {
33                let first_line = msg.content.lines().next().unwrap_or("").trim();
34                if !first_line.is_empty() && first_line.len() < 200 {
35                    topics.push(first_line.to_string());
36                }
37            }
38        }
39        topics.truncate(5);
40
41        if let Some(first_user) = messages.iter().find(|m| m.role == "user") {
42            let truncated = truncate_str(&first_user.content, 200);
43            summary_parts.push(format!("Started with: {truncated}"));
44        }
45        if let Some(last_assistant) = messages.iter().rev().find(|m| m.role == "assistant") {
46            let truncated = truncate_str(&last_assistant.content, 200);
47            summary_parts.push(format!("Concluded with: {truncated}"));
48        }
49        summary_parts.push(format!("Total turns: {turn_count}"));
50
51        let importance = calculate_importance(turn_count, topics.len());
52
53        Some(EpisodicDigest {
54            session_id: session.id.clone(),
55            agent_id: session.agent_id.clone(),
56            summary: summary_parts.join(". "),
57            key_topics: topics,
58            turn_count,
59            importance,
60        })
61    }
62
63    /// Store this digest in episodic memory.
64    pub fn persist(&self, db: &Database) -> roboticus_core::Result<String> {
65        let content = format!(
66            "[Session Digest] {}\nTopics: {}\nTurns: {}",
67            self.summary,
68            self.key_topics.join(", "),
69            self.turn_count,
70        );
71        let digest_id = roboticus_db::memory::store_episodic_with_meta(
72            db,
73            "digest",
74            &content,
75            self.importance,
76            Some(&self.agent_id),
77            "active",
78            None,
79        )?;
80        let _ = roboticus_db::memory::mark_episodic_digests_stale_for_owner(
81            db,
82            &self.agent_id,
83            &digest_id,
84            "superseded_by_newer_digest",
85        );
86        Ok(digest_id)
87    }
88}
89
90/// Calculate importance based on session engagement.
91fn calculate_importance(turn_count: i64, topic_count: usize) -> i32 {
92    let base = 5i32;
93    let turn_bonus = (turn_count as i32 / 5).min(3);
94    let topic_bonus = (topic_count as i32).min(2);
95    (base + turn_bonus + topic_bonus).min(10)
96}
97
98/// Apply exponential decay to a digest's importance based on age.
99pub fn decay_importance(original_importance: i32, age_days: f64, half_life_days: f64) -> i32 {
100    if half_life_days <= 0.0 {
101        return original_importance;
102    }
103    let decay_factor = (0.5_f64).powf(age_days / half_life_days);
104    let decayed = (original_importance as f64 * decay_factor).round() as i32;
105    decayed.max(1)
106}
107
108fn truncate_str(s: &str, max_len: usize) -> String {
109    if s.len() <= max_len {
110        s.to_string()
111    } else {
112        let boundary = s
113            .char_indices()
114            .take_while(|&(i, _)| i < max_len)
115            .last()
116            .map(|(i, c)| i + c.len_utf8())
117            .unwrap_or(0);
118        s[..boundary].to_string()
119    }
120}
121
122/// Generate and persist a digest for a session that is being archived/expired.
123pub fn digest_on_close(db: &Database, config: &DigestConfig, session: &Session) {
124    if !config.enabled {
125        debug!(session_id = %session.id, "digest generation disabled");
126        return;
127    }
128
129    match EpisodicDigest::from_session(db, session) {
130        Some(digest) => match digest.persist(db) {
131            Ok(id) => info!(
132                digest_id = %id,
133                session_id = %session.id,
134                topics = ?digest.key_topics,
135                importance = digest.importance,
136                "stored episodic digest"
137            ),
138            Err(e) => tracing::error!(error = %e, "failed to persist digest"),
139        },
140        None => debug!(session_id = %session.id, "no content to digest"),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn test_db() -> Database {
149        Database::new(":memory:").unwrap()
150    }
151
152    #[test]
153    fn empty_session_produces_no_digest() {
154        let db = test_db();
155        let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
156        let session = sessions::get_session(&db, &sid).unwrap().unwrap();
157        let digest = EpisodicDigest::from_session(&db, &session);
158        assert!(digest.is_none());
159    }
160
161    #[test]
162    fn session_with_messages_produces_digest() {
163        let db = test_db();
164        let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
165        sessions::append_message(&db, &sid, "user", "How do I sort a vector in Rust?").unwrap();
166        sessions::append_message(&db, &sid, "assistant", "Use vec.sort() or vec.sort_by()")
167            .unwrap();
168
169        let session = sessions::get_session(&db, &sid).unwrap().unwrap();
170        let digest = EpisodicDigest::from_session(&db, &session).unwrap();
171        assert_eq!(digest.session_id, sid);
172        assert!(!digest.summary.is_empty());
173        assert!(digest.summary.contains("sort"));
174        assert_eq!(digest.turn_count, 2);
175        assert!(!digest.key_topics.is_empty());
176    }
177
178    #[test]
179    fn digest_persist_stores_in_episodic_memory() {
180        let db = test_db();
181        let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
182        sessions::append_message(&db, &sid, "user", "Tell me about Rust").unwrap();
183        sessions::append_message(&db, &sid, "assistant", "Rust is a systems language").unwrap();
184
185        let session = sessions::get_session(&db, &sid).unwrap().unwrap();
186        let digest = EpisodicDigest::from_session(&db, &session).unwrap();
187        let id = digest.persist(&db).unwrap();
188        assert!(!id.is_empty());
189
190        let entries = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
191        let found = entries
192            .iter()
193            .any(|e| e.content.contains("[Session Digest]"));
194        assert!(found, "digest should be stored in episodic memory");
195    }
196
197    #[test]
198    fn calculate_importance_base() {
199        assert_eq!(calculate_importance(1, 0), 5);
200        assert_eq!(calculate_importance(5, 1), 7);
201        assert_eq!(calculate_importance(20, 5), 10);
202    }
203
204    #[test]
205    fn decay_importance_halves_at_half_life() {
206        assert_eq!(decay_importance(10, 7.0, 7.0), 5);
207    }
208
209    #[test]
210    fn decay_importance_zero_age_no_change() {
211        assert_eq!(decay_importance(8, 0.0, 7.0), 8);
212    }
213
214    #[test]
215    fn decay_importance_never_below_one() {
216        assert_eq!(decay_importance(2, 100.0, 7.0), 1);
217    }
218
219    #[test]
220    fn decay_importance_zero_half_life_no_decay() {
221        assert_eq!(decay_importance(8, 30.0, 0.0), 8);
222    }
223
224    #[test]
225    fn truncate_str_short() {
226        assert_eq!(truncate_str("hello", 10), "hello");
227    }
228
229    #[test]
230    fn truncate_str_long() {
231        let long = "a".repeat(300);
232        assert!(truncate_str(&long, 200).len() <= 200);
233    }
234
235    #[test]
236    fn digest_on_close_disabled() {
237        let db = test_db();
238        let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
239        sessions::append_message(&db, &sid, "user", "hello").unwrap();
240        let session = sessions::get_session(&db, &sid).unwrap().unwrap();
241
242        let config = DigestConfig {
243            enabled: false,
244            max_tokens: 512,
245            decay_half_life_days: 7,
246        };
247        digest_on_close(&db, &config, &session);
248
249        let entries = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
250        let has_digest = entries
251            .iter()
252            .any(|e| e.content.contains("[Session Digest]"));
253        assert!(!has_digest);
254    }
255
256    #[test]
257    fn digest_on_close_enabled() {
258        let db = test_db();
259        let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
260        sessions::append_message(&db, &sid, "user", "hello").unwrap();
261        sessions::append_message(&db, &sid, "assistant", "hi!").unwrap();
262        let session = sessions::get_session(&db, &sid).unwrap().unwrap();
263
264        let config = DigestConfig::default();
265        digest_on_close(&db, &config, &session);
266
267        let entries = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
268        let has_digest = entries
269            .iter()
270            .any(|e| e.content.contains("[Session Digest]"));
271        assert!(has_digest);
272    }
273
274    #[test]
275    fn topics_limited_to_five() {
276        let db = test_db();
277        let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
278        for i in 0..10 {
279            sessions::append_message(&db, &sid, "user", &format!("Topic {i}")).unwrap();
280            sessions::append_message(&db, &sid, "assistant", "response").unwrap();
281        }
282        let session = sessions::get_session(&db, &sid).unwrap().unwrap();
283        let digest = EpisodicDigest::from_session(&db, &session).unwrap();
284        assert!(digest.key_topics.len() <= 5);
285    }
286}